diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index c3adb5a7..f109e05c 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -49,6 +49,30 @@ export interface RouteSolveRequest { cross_half_width_m?: number | null; cross_sample_interval_m?: number | null; long_sample_interval_m?: number | null; + terrain_type?: string; + main_direction?: string; + max_grade_pct?: number | null; + min_vertical_radius_m?: number | null; + min_tangent_length_m?: number | null; + balance_segment_length_m?: number | null; + start_elevation_offset_m?: number | null; + end_elevation_offset_m?: number | null; +} + +/** 계획선 산출 요약 (실패 시 null) */ +export interface RouteGradeSummary { + id: string; + cut_area_m2: number; + fill_area_m2: number; + balance_error_m2: number; + max_grade_pct: number; + vertical_curve_count: number; + pvi_count: number; + balance_segment_count: number; + balanced: boolean; + main_direction: string; + suggested_elevation_offset_m: number | null; + warnings: string[]; } /** 경로 탐색 실행 결과 (RouteSolveResponse) */ @@ -62,6 +86,7 @@ export interface RouteSolveResponse { route_data_path: string; longitudinal_length_m: number | null; cross_section_count: number | null; + grade_summary: RouteGradeSummary | null; } /** 경로 확정 결과 (RouteConfirmResponse) */ @@ -107,6 +132,12 @@ export interface RouteLatestResponse { cross_half_width_m?: number | null; cross_sample_interval_m?: number | null; long_sample_interval_m?: number | null; + max_grade_pct?: number | null; + min_vertical_radius_m?: number | null; + min_tangent_length_m?: number | null; + balance_segment_length_m?: number | null; + start_elevation_offset_m?: number | null; + end_elevation_offset_m?: number | null; } | null; } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Grade.py b/B05_wf2_Route/B05_wf2_Route_Engine_Grade.py new file mode 100644 index 00000000..356f783d --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Grade.py @@ -0,0 +1,559 @@ +"""B05 종단 계획선(시공계획고) 산출 엔진. + +지반고 종단을 따라가면서 절토량과 성토량이 균형을 이루는 계획선을 만든다. +계획선은 일정 기울기의 직선(tangent)과 기울기 변화점에 삽입한 종단곡선 +(포물선)만으로 구성한다. 「임도설치 및 관리 등에 관한 규정」[별표 1-2]의 +다-(3)-(나) "시공계획고는 절토량과 성토량이 균형을 이루게 하되" 및 +다-(3)-(다) "종단기울기의 변화점에는 종단곡선을 삽입한다"가 근거다. + +절·성토 균형은 계획고와 지반고 차이의 적분(A안)으로 판정한다. 횡단 설계가 +나온 뒤 실제 단면적 기반 계획선을 추가할 수 있도록 결과는 배열의 한 원소로 +반환한다. +""" + +import math +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Solver import ( + balance_boundaries, + build_vertical_curves, + evaluate_profile, + grade_limits, + integration_weights, + optimize_pvi_elevations, + pvi_chainages, + segment_weights, +) +from config.config_system import ( + FOREST_ROAD_PROFILE_CRITERIA, + GRADE_MAIN_DIRECTIONS, + GRADE_TERRAIN_TYPES, +) + +GRADE_SCHEMA_VERSION = 1 +GRADE_PROFILE_ID = "design_grade_line" + +# 종단곡선 삽입 후 잔여 오차를 되먹임하는 최적화 반복 횟수 +_BALANCE_PASSES = 2 +# 시·종점 조정량 제안: 할선법 반복 횟수, 수렴 판정(m²), 제안 상한(m) +_OFFSET_SUGGESTION_ITERATIONS = 4 +_OFFSET_SUGGESTION_TOLERANCE_M2 = 1.0 +_OFFSET_SUGGESTION_MAX_M = 30.0 + + +@dataclass(frozen=True) +class GradeDesignOptions: + """계획선 설계 기준. 요청 값 → DB 저장 옵션 → config 순으로 결정된다.""" + + max_grade_pct: float + max_reverse_grade_pct: float + min_vertical_radius_m: float + min_curve_length_m: float + min_tangent_length_m: float + vertical_curve_skip_delta_pct: float + design_speed_kph: int + terrain_type: str = "normal" + paved: bool = False + main_direction: str = "auto" + balance_segment_length_m: float | None = None + start_elevation_offset_m: float = 0.0 + end_elevation_offset_m: float = 0.0 + warnings: tuple[str, ...] = field(default_factory=tuple) + + def validate(self) -> None: + values = { + "최대 종단기울기": self.max_grade_pct, + "종단곡선 최소 반경": self.min_vertical_radius_m, + "종단곡선 최소 길이": self.min_curve_length_m, + "최소 직선 길이": self.min_tangent_length_m, + } + for label, value in values.items(): + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{label}은 0보다 큰 유한한 값이어야 합니다.") + if self.terrain_type not in GRADE_TERRAIN_TYPES: + raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.") + if self.main_direction not in GRADE_MAIN_DIRECTIONS: + raise ValueError(f"주 진행방향은 {GRADE_MAIN_DIRECTIONS} 중 하나여야 합니다.") + if self.balance_segment_length_m is not None and self.balance_segment_length_m <= 0: + raise ValueError("균형 구역 길이는 0보다 큰 값이어야 합니다.") + + def as_dict(self) -> dict[str, Any]: + return { + "max_grade_pct": self.max_grade_pct, + "max_reverse_grade_pct": self.max_reverse_grade_pct, + "min_vertical_radius_m": self.min_vertical_radius_m, + "min_curve_length_m": self.min_curve_length_m, + "min_tangent_length_m": self.min_tangent_length_m, + "vertical_curve_skip_delta_pct": self.vertical_curve_skip_delta_pct, + "design_speed_kph": self.design_speed_kph, + "terrain_type": self.terrain_type, + "paved": self.paved, + "main_direction": self.main_direction, + "balance_segment_length_m": self.balance_segment_length_m, + "start_elevation_offset_m": self.start_elevation_offset_m, + "end_elevation_offset_m": self.end_elevation_offset_m, + } + + +def legal_grade_limit_pct( + grade_class: str, terrain_type: str = "normal", paved: bool = False +) -> float: + """설계속도 × 지형 구분에 따른 법정 종단기울기 상한(%). + + 특수지형 + 노면포장인 경우에 한하여 예외 상한까지 허용된다. + 경로탐색(느슨한 탐색 제약)과 달리 **위반 판정·계획선 기본값**의 기준이 된다. + """ + criteria = FOREST_ROAD_PROFILE_CRITERIA + terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" + speed = criteria["grade_to_design_speed"].get(grade_class, 20) + legal_max = float(criteria["design_speed"][speed]["max_grade_pct"][terrain]) + if paved and terrain == "special": + return float(criteria["paved_exception_grade_pct"]) + return legal_max + + +def _pick(*candidates: Any) -> Any: + """요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다.""" + for value in candidates: + if value is not None: + return value + return None + + +def resolve_grade_options( + grade_class: str, + *, + terrain_type: str = "normal", + paved: bool = False, + main_direction: str = "auto", + requested: dict[str, Any] | None = None, + stored: dict[str, Any] | None = None, +) -> GradeDesignOptions: + """임도 등급과 사용자 입력으로 계획선 설계 기준을 확정한다. + + `stored`에는 **사용자가 명시적으로 입력한 값만** 들어와야 한다. 해석이 끝난 + 기본값을 저장했다가 되읽으면, 등급·지형을 바꿔도 옛 기본값이 법정값을 이겨 + 갱신되지 않는다(예: 일반지형 8%가 굳어 특수지형 12%가 반영되지 않음). + """ + requested = requested or {} + stored = stored or {} + criteria = FOREST_ROAD_PROFILE_CRITERIA + terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" + speed = criteria["grade_to_design_speed"].get(grade_class, 20) + legal = criteria["design_speed"][speed] + legal_max = float(legal["max_grade_pct"][terrain]) + ceiling = legal_grade_limit_pct(grade_class, terrain, paved) + + max_grade = float(_pick(requested.get("max_grade_pct"), stored.get("max_grade_pct"), legal_max)) + warnings: list[str] = [] + if max_grade > ceiling + 1e-9: + warnings.append( + f"입력한 최대 종단기울기 {max_grade:.1f}%가 법정 상한 {ceiling:.1f}%를 초과합니다." + ) + return GradeDesignOptions( + max_grade_pct=max_grade, + max_reverse_grade_pct=float(legal["max_reverse_grade_pct"]), + min_vertical_radius_m=float( + _pick( + requested.get("min_vertical_radius_m"), + stored.get("min_vertical_radius_m"), + legal["min_vertical_radius_m"], + ) + ), + min_curve_length_m=float(legal["min_curve_length_m"]), + min_tangent_length_m=float( + _pick( + requested.get("min_tangent_length_m"), + stored.get("min_tangent_length_m"), + criteria["min_tangent_length_m"], + ) + ), + vertical_curve_skip_delta_pct=float(criteria["vertical_curve_skip_delta_pct"]), + design_speed_kph=int(speed), + terrain_type=terrain, + paved=bool(paved), + main_direction=(main_direction if main_direction in GRADE_MAIN_DIRECTIONS else "auto"), + balance_segment_length_m=_pick( + requested.get("balance_segment_length_m"), + stored.get("balance_segment_length_m"), + criteria["balance_segment_length_m"], + ), + start_elevation_offset_m=float( + _pick( + requested.get("start_elevation_offset_m"), + stored.get("start_elevation_offset_m"), + 0.0, + ) + ), + end_elevation_offset_m=float( + _pick( + requested.get("end_elevation_offset_m"), stored.get("end_elevation_offset_m"), 0.0 + ) + ), + warnings=tuple(warnings), + ) + + +def _ground_profile(longitudinal: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]: + """종단 샘플에서 (chainage, 지반고) 배열을 만든다. 결측은 선형 보간한다.""" + samples = longitudinal.get("samples") or [] + chainage = np.array([float(s.get("chainage_m", 0.0)) for s in samples], dtype=np.float64) + raw = np.array( + [ + float(s["elevation_m"]) + if s.get("valid", True) and s.get("elevation_m") is not None + else np.nan + for s in samples + ], + dtype=np.float64, + ) + order = np.argsort(chainage, kind="stable") + chainage, raw = chainage[order], raw[order] + finite = np.isfinite(raw) + if finite.sum() < 2 or len(chainage) < 2: + raise ValueError("계획선을 만들기에 유효한 종단 표고 샘플이 부족합니다.") + ground = np.interp(chainage, chainage[finite], raw[finite]) + return chainage, ground + + +def _detect_main_direction(ground: np.ndarray, rise: float) -> tuple[str, str | None]: + """지반 종단 형상에서 역기울기 판정 기준이 되는 주 진행방향을 정한다. + + 시·종점 고도차만으로 부호를 보면 V자(계곡 횡단)·Λ자(능선 통과) 노선에서 + 오판한다. 고도차가 전체 기복(최고−최저)에 비해 충분히 클 때만 한 방향으로 + 오르내리는 노선으로 보고, 그렇지 않으면 역기울기를 적용하지 않는다. + + 반환값은 (방향, 사용자 안내 문구 또는 None) 이다. + """ + relief = float(np.max(ground) - np.min(ground)) + if relief <= 1e-6: + return "none", None + ratio = abs(rise) / relief + if ratio >= float(FOREST_ROAD_PROFILE_CRITERIA["main_direction_monotone_ratio"]): + return ("ascending" if rise > 0 else "descending"), None + return "none", ( + f"시·종점 고도차({rise:+.1f}m)가 전체 기복({relief:.1f}m)에 비해 작아 주 진행방향을 " + "특정할 수 없습니다(계곡 횡단·능선 통과 형상). 역기울기 상한 대신 양방향 모두 " + "순기울기 상한을 적용했습니다." + ) + + +def _segment_metrics( + chainage: np.ndarray, + difference: np.ndarray, + boundaries: np.ndarray, +) -> list[dict[str, Any]]: + """구역별 절토·성토 면적과 균형 오차를 계산한다.""" + metrics: list[dict[str, Any]] = [] + for index, (lower, upper) in enumerate(zip(boundaries[:-1], boundaries[1:])): + mask, local_weight = segment_weights(chainage, float(lower), float(upper)) + local_difference = difference[mask] + cut = float(local_weight[local_difference < 0] @ -local_difference[local_difference < 0]) + fill = float(local_weight[local_difference > 0] @ local_difference[local_difference > 0]) + metrics.append( + { + "index": index, + "start_chainage_m": round(float(lower), 6), + "end_chainage_m": round(float(upper), 6), + "cut_area_m2": round(cut, 6), + "fill_area_m2": round(fill, 6), + "balance_error_m2": round(fill - cut, 6), + } + ) + return metrics + + +def _balance_error( + chainage: np.ndarray, + ground: np.ndarray, + weights: np.ndarray, + boundaries: np.ndarray, + fixed: tuple[float, float], + options: GradeDesignOptions, + limits: tuple[float, float], +) -> float: + """주어진 시·종점 고도로 풀었을 때의 전체 절·성토 적분 오차(m²).""" + pvi_s, pvi_z, curves, _converged, _warnings = _solve_balanced_profile( + chainage, ground, weights, boundaries, fixed, options, limits + ) + difference = evaluate_profile(pvi_s, pvi_z, curves, chainage) - ground + return float(weights @ difference) + + +def _suggest_elevation_offset( + chainage: np.ndarray, + ground: np.ndarray, + weights: np.ndarray, + boundaries: np.ndarray, + options: GradeDesignOptions, + limits: tuple[float, float], + current_error: float, +) -> float | None: + """시·종점을 같은 양만큼 올리거나 내려 균형을 맞출 수 있는 조정량을 추정한다. + + BP·EP가 능선 정상에 놓이면 계획선이 지반 위로만 떠서 절토가 원천적으로 나오지 + 않는다. 시·종점을 δ만큼 내리면 적분 오차가 대략 δ·L 만큼 줄어드는 성질을 초기 + 추정으로 삼고, 실제 해로 두 번 보정(할선법)한다. 반환값은 사용자에게 제안만 + 하며 자동 적용하지 않는다(시·종점은 기존 도로와의 접속점이기 때문). + """ + total = float(chainage[-1]) or 1.0 + guess = -current_error / total + previous_offset, previous_error = 0.0, current_error + for _ in range(_OFFSET_SUGGESTION_ITERATIONS): + try: + error = _balance_error( + chainage, + ground, + weights, + boundaries, + ( + float(ground[0]) + guess, + float(ground[-1]) + guess, + ), + options, + limits, + ) + except (ValueError, np.linalg.LinAlgError): + return None + if abs(error) <= _OFFSET_SUGGESTION_TOLERANCE_M2: + return guess + slope = (error - previous_error) / (guess - previous_offset or 1e-9) + if abs(slope) < 1e-9: + return None + previous_offset, previous_error = guess, error + guess = guess - error / slope + if not math.isfinite(guess) or abs(guess) > _OFFSET_SUGGESTION_MAX_M: + return None + return guess if abs(previous_error) < abs(current_error) else None + + +def _solve_balanced_profile( + chainage: np.ndarray, + ground: np.ndarray, + weights: np.ndarray, + boundaries: np.ndarray, + fixed: tuple[float, float], + options: GradeDesignOptions, + limits: tuple[float, float], +) -> tuple[np.ndarray, np.ndarray, list[dict[str, Any]], bool, list[str]]: + """주어진 균형 구역 구성으로 PVI 표고와 종단곡선을 확정한다. + + 종단곡선(포물선)은 직선 폴리라인 대비 A·L²/24 만큼 면적을 바꾸므로, 그 값을 + 다음 반복의 목표 적분값으로 되먹여 곡선까지 반영한 최종 적분이 0이 되게 한다. + """ + total = float(chainage[-1]) + pvi_s = pvi_chainages(boundaries, total, options.min_tangent_length_m) + up_limit, down_limit = limits + targets = np.zeros(len(boundaries) - 1, dtype=np.float64) + pvi_z = np.zeros_like(pvi_s) + curves: list[dict[str, Any]] = [] + curve_warnings: list[str] = [] + converged = True + for _pass in range(_BALANCE_PASSES): + pvi_z, converged = optimize_pvi_elevations( + pvi_s, chainage, ground, weights, boundaries, targets, fixed, up_limit, down_limit + ) + curves, curve_warnings = build_vertical_curves( + pvi_s, + pvi_z, + radius_m=options.min_vertical_radius_m, + min_curve_length_m=options.min_curve_length_m, + skip_delta_pct=options.vertical_curve_skip_delta_pct, + paved=options.paved, + ) + corrections = np.zeros_like(targets) + for curve in curves: + segment = int( + np.clip( + np.searchsorted(boundaries, curve["chainage_m"], "right") - 1, + 0, + len(targets) - 1, + ) + ) + corrections[segment] -= curve["area_offset_m2"] + if np.allclose(corrections, targets, atol=1e-6): + break + targets = corrections + return pvi_s, pvi_z, curves, converged, curve_warnings + + +def design_grade_line(longitudinal: dict[str, Any], options: GradeDesignOptions) -> dict[str, Any]: + """종단 지반고에서 절·성토 균형 계획선을 산출한다. + + 반환 dict는 longitudinal.json의 `design_profiles` 배열에 그대로 넣는다. + """ + options.validate() + chainage, ground = _ground_profile(longitudinal) + total = float(chainage[-1]) + if total <= 0: + raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.") + + weights = integration_weights(chainage) + boundaries = balance_boundaries(total, options.balance_segment_length_m) + fixed = ( + float(ground[0]) + options.start_elevation_offset_m, + float(ground[-1]) + options.end_elevation_offset_m, + ) + rise = fixed[1] - fixed[0] + warnings = list(options.warnings) + direction, direction_note = ( + _detect_main_direction(ground, rise) + if options.main_direction == "auto" + else (options.main_direction, None) + ) + if direction_note: + warnings.append(direction_note) + limits = grade_limits(options.max_grade_pct, options.max_reverse_grade_pct, direction) + up_limit, down_limit = limits + limit = up_limit if rise >= 0 else down_limit + if abs(rise) / total > limit + 1e-9: + raise ValueError( + f"시·종점 고도차({rise:.2f}m)를 연장 {total:.1f}m에서 기준 기울기 " + f"{limit * 100:.1f}% 이내로 연결할 수 없습니다." + ) + + pvi_s, pvi_z, curves, converged, curve_warnings = _solve_balanced_profile( + chainage, ground, weights, boundaries, fixed, options, limits + ) + if not converged and len(boundaries) > 2: + # 구역별 정확 균형이 기울기 기준과 양립하지 않는 경우: 절·성토 균형은 법정 + # 요건(다-(3)-(나))이므로, 구역 분할을 포기하고 노선 전체 1구역으로 다시 + # 풀어 정확 균형을 우선한다. 그래도 안 되면 그 결과(근사 균형)를 그대로 쓴다. + full_boundaries = np.array([0.0, total], dtype=np.float64) + full_pvi_s, full_pvi_z, full_curves, full_converged, full_curve_warnings = ( + _solve_balanced_profile( + chainage, ground, weights, full_boundaries, fixed, options, limits + ) + ) + if full_converged: + warnings.append( + f"요청한 균형 구역 길이({options.balance_segment_length_m:.0f}m)로는 기울기 " + "기준을 지키며 정확히 균형시킬 수 없어, 노선 전체 1구역 균형으로 대체했습니다." + ) + boundaries, pvi_s, pvi_z, curves, converged, curve_warnings = ( + full_boundaries, + full_pvi_s, + full_pvi_z, + full_curves, + full_converged, + full_curve_warnings, + ) + warnings.extend(curve_warnings) + + plan = evaluate_profile(pvi_s, pvi_z, curves, chainage) + difference = plan - ground + segments = _segment_metrics(chainage, difference, boundaries) + + # 균형이 맞지 않고 시·종점이 지반고에 고정돼 있으면, 어느 정도 조정하면 균형이 + # 맞는지 계산해 제안한다. 시·종점은 기존 도로 접속점이라 자동 적용하지 않는다. + suggested_offset: float | None = None + if not converged: + warnings.append( + "기울기 기준을 지키면서 절·성토를 정확히 균형시킬 수 없어 근사 균형을 적용했습니다." + ) + pinned = ( + abs(options.start_elevation_offset_m) < 1e-9 + and abs(options.end_elevation_offset_m) < 1e-9 + ) + if pinned: + suggested_offset = _suggest_elevation_offset( + chainage, ground, weights, boundaries, options, limits, float(weights @ difference) + ) + if suggested_offset is not None: + warnings.append( + f"시·종점 계획고를 각각 {suggested_offset:+.1f}m 조정하면 균형을 맞출 수 " + "있습니다(시점·종점 계획고 조정 입력란)." + ) + else: + warnings.append("시·종점 계획고 조정 또는 기울기·지형 구분 기준을 확인하세요.") + grades = (pvi_z[1:] - pvi_z[:-1]) / (pvi_s[1:] - pvi_s[:-1]) + max_grade_pct = float(np.max(np.abs(grades)) * 100.0) if len(grades) else 0.0 + if max_grade_pct > options.max_grade_pct + 1e-6: + warnings.append( + f"산출된 최대 종단기울기 {max_grade_pct:.2f}%가 기준 " + f"{options.max_grade_pct:.2f}%를 초과합니다." + ) + + curve_by_index = {curve["index"]: curve for curve in curves} + pvis = [ + { + "chainage_m": round(float(pvi_s[index]), 6), + "elevation_m": round(float(pvi_z[index]), 6), + "kind": "bp" if index == 0 else "ep" if index == len(pvi_s) - 1 else "pvi", + "grade_in_pct": round(float(grades[index - 1]) * 100.0, 6) if index > 0 else None, + "grade_out_pct": round(float(grades[index]) * 100.0, 6) + if index < len(grades) + else None, + "radius_m": round(float(curve_by_index[index]["radius_m"]), 6) + if index in curve_by_index + else None, + "curve_length_m": round(float(curve_by_index[index]["length_m"]), 6) + if index in curve_by_index + else None, + } + for index in range(len(pvi_s)) + ] + + station_chainage = np.array( + [float(station["chainage_m"]) for station in longitudinal.get("stations") or []], + dtype=np.float64, + ) + station_plan = ( + evaluate_profile(pvi_s, pvi_z, curves, station_chainage) + if len(station_chainage) + else np.array([]) + ) + station_ground = ( + np.interp(station_chainage, chainage, ground) if len(station_chainage) else np.array([]) + ) + stations = [ + { + "station_id": station.get("station_id"), + "chainage_m": round(float(station_chainage[index]), 6), + "plan_elevation_m": round(float(station_plan[index]), 6), + "ground_elevation_m": round(float(station_ground[index]), 6), + "cut_m": round(max(float(station_ground[index] - station_plan[index]), 0.0), 6), + "fill_m": round(max(float(station_plan[index] - station_ground[index]), 0.0), 6), + } + for index, station in enumerate(longitudinal.get("stations") or []) + ] + + cut_area = float(weights[difference < 0] @ -difference[difference < 0]) + fill_area = float(weights[difference > 0] @ difference[difference > 0]) + return { + "schema_version": GRADE_SCHEMA_VERSION, + "id": GRADE_PROFILE_ID, + "name": "계획선", + "basis": "longitudinal_balance", + "criteria": {**options.as_dict(), "resolved_main_direction": direction}, + "pvis": pvis, + "samples": [ + { + "chainage_m": round(float(chainage[index]), 6), + "elevation_m": round(float(plan[index]), 6), + "ground_elevation_m": round(float(ground[index]), 6), + "difference_m": round(float(difference[index]), 6), + } + for index in range(len(chainage)) + ], + "stations": stations, + "balance_segments": segments, + "summary": { + "cut_area_m2": round(cut_area, 6), + "fill_area_m2": round(fill_area, 6), + "balance_error_m2": round(fill_area - cut_area, 6), + "max_grade_pct": round(max_grade_pct, 6), + "vertical_curve_count": len(curves), + "pvi_count": len(pvi_s), + "balance_segment_count": len(segments), + "balanced": bool(converged), + "main_direction": direction, + "suggested_elevation_offset_m": ( + round(float(suggested_offset), 3) if suggested_offset is not None else None + ), + "warnings": warnings, + }, + } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Solver.py b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Solver.py new file mode 100644 index 00000000..5ca57da1 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Grade_Solver.py @@ -0,0 +1,351 @@ +"""B05 종단 계획선 산출의 수치 계산부 (적분 가중치·PVI 최적화·종단곡선 기하). + +[[B05_wf2_Route_Engine_Grade]] 가 설계 기준을 확정한 뒤 이 모듈의 함수를 호출한다. +설계 기준 객체에 의존하지 않고 스칼라 인자만 받아, 기준 해석과 수치 계산을 +분리한다(700줄 제한에 따른 분할). +""" + +from typing import Any, Callable + +import numpy as np +from scipy.optimize import LinearConstraint, minimize + +# PVI 간격 산정: 최소 직선길이의 2배 이상을 두되 노선을 최대 이 개수로 나눈다. +_MAX_PVI_SEGMENTS = 40 +# 종단곡선이 인접 곡선과 겹치지 않도록 확보하는 여유 비율 +_CURVE_OVERLAP_RATIO = 0.9 +# 구역 균형이 맞았다고 볼 평균 고저차 허용 오차 (m) +BALANCE_TOLERANCE_M = 1e-6 +# 정확 균형이 기울기 기준과 양립하지 않을 때 쓰는 연화(soft) 균형 가중치 +_SOFT_BALANCE_WEIGHT = 1.0e4 +_TRUST_OPTIONS = {"maxiter": 1000, "gtol": 1e-10, "xtol": 1e-12} +_SLSQP_OPTIONS = {"maxiter": 500, "ftol": 1e-12} + + +def integration_weights(chainage: np.ndarray) -> np.ndarray: + """사다리꼴 적분용 샘플 가중치(ds).""" + weights = np.zeros_like(chainage) + weights[1:-1] = (chainage[2:] - chainage[:-2]) / 2.0 + weights[0] = (chainage[1] - chainage[0]) / 2.0 + weights[-1] = (chainage[-1] - chainage[-2]) / 2.0 + return weights + + +def segment_weights( + chainage: np.ndarray, lower: float, upper: float +) -> tuple[np.ndarray, np.ndarray]: + """[lower, upper] 구간에 국한된 사다리꼴 적분 가중치를 만든다. + + 전역 가중치를 그대로 쓰면 구역 경계 샘플의 반폭이 옆 구역까지 걸쳐 있어 + 구역 면적이 과대 계상된다(가중치 합이 구간 길이와 어긋난다). + """ + mask = (chainage >= lower - 1e-9) & (chainage <= upper + 1e-9) + local = chainage[mask] + weights = np.zeros(len(local), dtype=np.float64) + if len(local) < 2: + return mask, weights + weights[1:-1] = (local[2:] - local[:-2]) / 2.0 + weights[0] = (local[1] - local[0]) / 2.0 + weights[-1] = (local[-1] - local[-2]) / 2.0 + return mask, weights + + +def balance_boundaries(total: float, segment_length: float | None) -> np.ndarray: + """절·성토 균형을 맞출 구역 경계 chainage(시·종점 포함)를 만든다.""" + if not segment_length or segment_length >= total: + return np.array([0.0, total], dtype=np.float64) + count = max(1, int(round(total / segment_length))) + return np.linspace(0.0, total, count + 1, dtype=np.float64) + + +def pvi_chainages(boundaries: np.ndarray, total: float, min_tangent: float) -> np.ndarray: + """균형 구역 경계를 반드시 포함하는 PVI 후보 chainage를 배치한다.""" + spacing = max(min_tangent * 2.0, total / _MAX_PVI_SEGMENTS) + nodes: list[float] = [0.0] + for start, end in zip(boundaries[:-1], boundaries[1:]): + span = float(end - start) + count = max(1, int(span // spacing)) + nodes.extend(float(value) for value in np.linspace(start, end, count + 1)[1:]) + values = np.unique(np.round(np.array(nodes, dtype=np.float64), 6)) + return values[(values >= -1e-9) & (values <= total + 1e-9)] + + +def _interp_matrix(pvi_s: np.ndarray, targets: np.ndarray) -> np.ndarray: + """PVI 표고 벡터를 샘플 chainage의 직선 보간 표고로 옮기는 행렬.""" + matrix = np.zeros((len(targets), len(pvi_s)), dtype=np.float64) + index = np.clip(np.searchsorted(pvi_s, targets, side="right") - 1, 0, len(pvi_s) - 2) + span = pvi_s[index + 1] - pvi_s[index] + ratio = np.where(span > 0, (targets - pvi_s[index]) / np.where(span > 0, span, 1.0), 0.0) + rows = np.arange(len(targets)) + matrix[rows, index] = 1.0 - ratio + matrix[rows, index + 1] = ratio + return matrix + + +def _polyline_area_matrix(pvi_s: np.ndarray, lower: float, upper: float) -> np.ndarray: + """[lower, upper] 구간에서 PVI 직선 폴리라인의 면적 적분 계수 벡터.""" + coefficients = np.zeros(len(pvi_s), dtype=np.float64) + for index in range(len(pvi_s) - 1): + start, end = float(pvi_s[index]), float(pvi_s[index + 1]) + if end <= lower + 1e-9 or start >= upper - 1e-9: + continue + length = end - start + coefficients[index] += length / 2.0 + coefficients[index + 1] += length / 2.0 + return coefficients + + +def grade_limits( + max_grade_pct: float, max_reverse_grade_pct: float, main_direction: str +) -> tuple[float, float]: + """주 진행방향 기준 (상승 상한, 하강 상한)을 비율로 정한다. + + 주 진행방향과 반대인 쪽이 역기울기이므로 더 엄한 상한을 받는다. + `none`이면 주 진행방향을 특정할 수 없는 노선(V자·Λ자)이므로 양방향 모두 + 순기울기 상한을 적용한다. 방향 판정은 [[_detect_main_direction]] 이 한다. + """ + main = max_grade_pct / 100.0 + reverse = min(max_reverse_grade_pct, max_grade_pct) / 100.0 + if main_direction == "ascending": + return main, reverse + if main_direction == "descending": + return reverse, main + return main, main + + +def _best_solution( + attempts: list[tuple[str, np.ndarray]], + objective: Callable[[np.ndarray], float], + objective_jac: Callable[[np.ndarray], np.ndarray], + hessian: np.ndarray, + constraints: list[LinearConstraint], + violation: Callable[[np.ndarray], float], +) -> tuple[np.ndarray, float]: + """여러 (해법, 초기값) 조합을 시도해 제약 위반이 가장 작은 해를 고른다. + + trust-constr는 활성 제약이 많아지면 Jacobian이 특이해져 엉뚱한 점에서 멈추는 + 경우가 있다. 결과를 직접 검증하고 통과하면 즉시 채택, 아니면 다음 조합으로 + 넘어가되 최소 위반 해를 남긴다. + """ + best: tuple[np.ndarray, float] | None = None + for method, initial in attempts: + extra = {"hess": lambda _x: hessian} if method == "trust-constr" else {} + settings = _TRUST_OPTIONS if method == "trust-constr" else _SLSQP_OPTIONS + try: + result = minimize( + objective, + initial, + jac=objective_jac, + constraints=constraints, + method=method, + options=settings, + **extra, + ) + except (ValueError, np.linalg.LinAlgError): + continue + score = violation(result.x) + if best is None or score < best[1]: + best = (np.asarray(result.x, dtype=np.float64), score) + if score <= BALANCE_TOLERANCE_M: + break + if best is None: + return np.asarray(attempts[0][1], dtype=np.float64), float("inf") + return best + + +def optimize_pvi_elevations( + pvi_s: np.ndarray, + chainage: np.ndarray, + ground: np.ndarray, + weights: np.ndarray, + boundaries: np.ndarray, + targets: np.ndarray, + fixed: tuple[float, float], + up_limit: float, + down_limit: float, +) -> tuple[np.ndarray, bool]: + """구역별 절·성토 균형을 등식 제약으로 두고 PVI 표고를 최적화한다. + + 목적함수는 지반 추종(∫(계획고−지반고)²ds), 등식 제약은 구역별 + ∫(계획고−지반고)ds = target, 부등식 제약은 구간별 종단기울기 상한이다. + 두 번째 반환값은 정확 균형 달성 여부다. + """ + matrix = _interp_matrix(pvi_s, chainage) + free = np.arange(1, len(pvi_s) - 1) + base = np.zeros(len(pvi_s), dtype=np.float64) + base[0], base[-1] = fixed + if not len(free): + return base, True + + matrix_free = matrix[:, free] + offset = matrix @ base - ground + # 노선 길이로 정규화해 목적함수와 제약의 스케일 차이를 없앤다. + scale = float(weights.sum()) or 1.0 + normalized = weights / scale + # 목적함수가 순수 이차식이므로 Hessian을 해석적으로 넘겨 QP로 정확히 푼다. + hessian = 2.0 * (matrix_free.T @ (normalized[:, None] * matrix_free)) + + def objective(x: np.ndarray) -> float: + residual = matrix_free @ x + offset + return float(residual @ (normalized * residual)) + + def objective_jac(x: np.ndarray) -> np.ndarray: + residual = matrix_free @ x + offset + return 2.0 * (matrix_free.T @ (normalized * residual)) + + equality: list[np.ndarray] = [] + equality_rhs: list[float] = [] + for index, (lower, upper) in enumerate(zip(boundaries[:-1], boundaries[1:])): + coefficients = _polyline_area_matrix(pvi_s, float(lower), float(upper)) + mask, local_weights = segment_weights(chainage, float(lower), float(upper)) + ground_area = float(local_weights @ ground[mask]) + # 구역 길이로 나누어 면적(m²)이 아닌 평균 고저차(m) 단위로 다룬다. + span = max(float(upper - lower), 1e-9) + equality.append(coefficients[free] / span) + equality_rhs.append( + (ground_area + float(targets[index]) - float(coefficients @ base)) / span + ) + equality_matrix = np.vstack(equality) + equality_vector = np.array(equality_rhs, dtype=np.float64) + + spans = pvi_s[1:] - pvi_s[:-1] + difference = np.zeros((len(spans), len(pvi_s)), dtype=np.float64) + rows = np.arange(len(spans)) + difference[rows, rows] = -1.0 / spans + difference[rows, rows + 1] = 1.0 / spans + difference_free = difference[:, free] + difference_base = difference @ base + + # 종단기울기 상한은 법정 기준이므로 항상 강제 제약으로 둔다. + grade_constraint = LinearConstraint( + difference_free, -down_limit - difference_base, up_limit - difference_base + ) + balance_constraint = LinearConstraint(equality_matrix, equality_vector, equality_vector) + + def grade_violation(x: np.ndarray) -> float: + grades = difference_free @ x + difference_base + return float( + max(np.max(grades - up_limit, initial=0.0), np.max(-down_limit - grades, initial=0.0)) + ) + + def total_violation(x: np.ndarray) -> float: + balance = float(np.max(np.abs(equality_matrix @ x - equality_vector), initial=0.0)) + return max(grade_violation(x), balance) + + # 시·종점 직선과 지반 추종, 두 출발점을 준비한다(전자는 기울기 제약을 자명하게 만족). + straight = np.interp( + pvi_s[free], + [float(pvi_s[0]), float(pvi_s[-1])], + [float(base[0]), float(base[-1])], + ) + following = np.interp(pvi_s[free], chainage, ground) + attempts = [ + ("trust-constr", straight), + ("SLSQP", straight), + ("trust-constr", following), + ] + elevations = base.copy() + solution, violation = _best_solution( + attempts, + objective, + objective_jac, + hessian, + [balance_constraint, grade_constraint], + total_violation, + ) + if violation <= BALANCE_TOLERANCE_M: + elevations[free] = solution + return elevations, True + + # 정확 균형이 기울기 기준과 양립하지 않는 경우: 균형을 벌점으로 완화하되 + # 법정 기울기 제약은 그대로 강제한 채 불균형이 최소인 해를 찾는다. + soft_hessian = hessian + 2.0 * _SOFT_BALANCE_WEIGHT * (equality_matrix.T @ equality_matrix) + + def soft_objective(x: np.ndarray) -> float: + gap = equality_matrix @ x - equality_vector + return objective(x) + _SOFT_BALANCE_WEIGHT * float(gap @ gap) + + def soft_jac(x: np.ndarray) -> np.ndarray: + gap = equality_matrix @ x - equality_vector + return objective_jac(x) + 2.0 * _SOFT_BALANCE_WEIGHT * (equality_matrix.T @ gap) + + solution, _ = _best_solution( + attempts, soft_objective, soft_jac, soft_hessian, [grade_constraint], grade_violation + ) + elevations[free] = solution + return elevations, False + + +def build_vertical_curves( + pvi_s: np.ndarray, + pvi_z: np.ndarray, + *, + radius_m: float, + min_curve_length_m: float, + skip_delta_pct: float, + paved: bool, +) -> tuple[list[dict[str, Any]], list[str]]: + """기울기 변화점에 종단곡선(포물선)을 삽입하고 곡선 제원을 만든다.""" + spans = pvi_s[1:] - pvi_s[:-1] + grades = (pvi_z[1:] - pvi_z[:-1]) / spans + skip_delta = skip_delta_pct / 100.0 + curves: list[dict[str, Any]] = [] + warnings: list[str] = [] + for index in range(1, len(pvi_s) - 1): + grade_in, grade_out = float(grades[index - 1]), float(grades[index]) + delta = grade_out - grade_in + if abs(delta) < 1e-9: + continue + # 비포장 도로이면서 대수차가 기준 이하이면 법정 예외로 곡선을 두지 않는다. + if not paved and abs(delta) <= skip_delta + 1e-12: + continue + length = radius_m * abs(delta) + available = min(float(spans[index - 1]), float(spans[index])) * _CURVE_OVERLAP_RATIO + if length > available: + length = available + warnings.append( + f"chainage {pvi_s[index]:.1f}m: 인접 직선이 짧아 종단곡선 길이를 " + f"{length:.1f}m로 줄였습니다." + ) + if length < min_curve_length_m - 1e-9: + warnings.append( + f"chainage {pvi_s[index]:.1f}m: 종단곡선 길이 {length:.1f}m가 " + f"기준 {min_curve_length_m:.1f}m에 미달합니다." + ) + if length <= 1e-9: + continue + curves.append( + { + "index": index, + "chainage_m": float(pvi_s[index]), + "elevation_m": float(pvi_z[index]), + "grade_in": grade_in, + "grade_out": grade_out, + "length_m": float(length), + "radius_m": float(length / abs(delta)), + # 포물선 삽입으로 직선 폴리라인 대비 발생하는 면적 차 (A·L²/24) + "area_offset_m2": float(delta * length * length / 24.0), + } + ) + return curves, warnings + + +def evaluate_profile( + pvi_s: np.ndarray, pvi_z: np.ndarray, curves: list[dict[str, Any]], targets: np.ndarray +) -> np.ndarray: + """직선 + 종단곡선으로 구성된 계획선을 임의 chainage에서 평가한다.""" + values = np.interp(targets, pvi_s, pvi_z) + for curve in curves: + half = curve["length_m"] / 2.0 + start = curve["chainage_m"] - half + end = curve["chainage_m"] + half + mask = (targets >= start) & (targets <= end) + if not np.any(mask): + continue + local = targets[mask] - start + start_z = curve["elevation_m"] - curve["grade_in"] * half + delta = curve["grade_out"] - curve["grade_in"] + values[mask] = ( + start_z + curve["grade_in"] * local + delta / (2.0 * curve["length_m"]) * local * local + ) + return values diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_RidgeValley.py b/B05_wf2_Route/B05_wf2_Route_Engine_RidgeValley.py index 3e063523..cb7714a2 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_RidgeValley.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_RidgeValley.py @@ -487,11 +487,14 @@ def _fillet_alignment( def resolve_grade_bounds(options: dict[str, Any]) -> dict[str, float]: - """options에서 방향별 경사 상/하한을 해석한다.""" + """options에서 방향별 경사 상/하한을 해석한다. + + options의 경사 값 단위는 퍼센트(%)이므로 비율로 환산한다. default는 이미 비율이다. + """ def _opt(key: str, default: float) -> float: v = options.get(key) - return float(v) if v is not None else default + return float(v) / 100.0 if v is not None else default min_uphill_grade = _opt("min_uphill_grade", ROUTE_ALT_MIN_GRADE) min_downhill_grade = _opt("min_downhill_grade", ROUTE_ALT_MIN_GRADE) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index 00799058..67c56cde 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -7,9 +7,11 @@ longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB """ import json +import logging from pathlib import Path from typing import Any +from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, design_grade_line from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import ( SectionGenerationOptions, generate_sections, @@ -17,6 +19,8 @@ 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 +logger = logging.getLogger(__name__) + _STAGE_SUBDIR = Path("B06_wf3_ProfileCross") _MODELS_SUBDIR = Path("B04_wf1_Surface") / "models" @@ -73,6 +77,26 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]: } +def _append_design_profiles( + longitudinal: dict[str, Any], grade_options: GradeDesignOptions | None +) -> dict[str, Any] | None: + """종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다. + + 계획선 산출 실패가 종횡단 생성 자체를 무효화하지 않도록 예외를 격리한다. + 횡단 설계 기반 계획선을 나중에 추가할 수 있게 배열로 보관한다. + """ + longitudinal.setdefault("design_profiles", []) + if grade_options is None: + return None + try: + profile = design_grade_line(longitudinal, grade_options) + except (ValueError, KeyError, ArithmeticError): + logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)") + return None + longitudinal["design_profiles"].append(profile) + return {"id": profile["id"], **profile["summary"]} + + def run_section_generation( project_root: Path, route_data_path: str, @@ -81,6 +105,8 @@ def run_section_generation( smooth: bool, *, options: SectionGenerationOptions | None = None, + grade_options: GradeDesignOptions | None = None, + grade_overrides: dict[str, Any] | None = None, crs: str | None = None, ) -> dict[str, Any]: """종횡단을 생성·저장하고 DB 기록용 데이터를 반환한다. @@ -108,7 +134,8 @@ def run_section_generation( long_dir.mkdir(parents=True, exist_ok=True) cross_dir.mkdir(parents=True, exist_ok=True) - # 종단면 저장 + # 종단면 저장 (계획선은 저장 직전에 종단 데이터에 붙인다) + grade_summary = _append_design_profiles(result["longitudinal"], grade_options) long_file = long_dir / "longitudinal.json" atomic_write_json(long_file, result["longitudinal"]) long_summary = { @@ -117,6 +144,14 @@ def run_section_generation( "invalid_samples": result["summary"]["invalid_longitudinal_samples"], # 사용자 선택값의 단일 소스(DB): 재생성·재탐색 시 이 값을 우선 사용한다. "options": result["options"], + # 해석이 끝난 기준값은 기록·표시용으로만 보관한다. 이 값을 재계산 시 + # 폴백으로 되쓰면 등급·지형을 바꿔도 옛 기본값이 법정값을 이겨 갱신되지 않는다. + "grade_options": grade_options.as_dict() if grade_options else None, + # 재계산 폴백에 쓰는 단일 소스: 사용자가 명시적으로 입력한 값만 담는다. + "grade_overrides": { + key: value for key, value in (grade_overrides or {}).items() if value is not None + }, + "grade_summary": grade_summary, } # 측점별 횡단면 저장 (detail 조회가 폴더 전체를 glob하므로 이전 실행 잔재를 먼저 비운다) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Solver.py b/B05_wf2_Route/B05_wf2_Route_Engine_Solver.py index 3f0ca367..f1d03aac 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Solver.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Solver.py @@ -371,8 +371,9 @@ def solve_optimal_route( min_curve_radius_m = FOREST_ROAD_MIN_CURVE_R_M.get(grade_class, 12.0) def _grade_opt(key: str) -> float: + """options의 경사 값은 퍼센트(%)이므로 비율로 환산한다.""" v = options.get(key) - return float(v) if (v is not None and float(v) > 0) else max_grade + return float(v) / 100.0 if (v is not None and float(v) > 0) else max_grade max_uphill_grade = _grade_opt("max_uphill_grade") max_downhill_grade = _grade_opt("max_downhill_grade") @@ -495,6 +496,9 @@ def solve_optimal_route( length_m += h_dist grade_sums += segment_slope * h_dist max_grade_pct = max(max_grade_pct, segment_slope) + # 여기서 다루는 z는 **지반고**다. 산지 지형이 급한 것은 위반이 아니므로 + # 법정 종단기울기가 아니라 탐색 제약(선형을 완만한 지형으로 유도하는 값)과 + # 대조한다. 법정 기준 준수는 절·성토 후 노면이 되는 계획선이 담당한다. applicable = max_uphill_grade if dz > 0 else max_downhill_grade if dz > 0: max_uphill_pct = max(max_uphill_pct, segment_slope) @@ -646,7 +650,10 @@ def solve_optimal_route( "max_grade_pct": round(max_grade_pct * 100, 2), "max_uphill_pct": round(max_uphill_pct * 100, 2), "max_downhill_pct": round(max_downhill_pct * 100, 2), + # 아래 경사 지표는 모두 경로가 지나는 **지반고** 기준이다(노면고 아님). "slope_violations": slope_violations, + # slope_violations의 판정 기준 = 탐색 제약. 법정 기준과 혼동하지 않도록 노출한다. + "search_max_grade_pct": round(max(max_uphill_grade, max_downhill_grade) * 100, 2), "curve_violations": curve_violations, "min_curve_radius_m": round(min_curve_radius_actual, 2) if math.isfinite(min_curve_radius_actual) diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index 7e487a26..56d143a7 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design +from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, resolve_grade_options from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions from B05_wf2_Route.B05_wf2_Route_Repository import ( @@ -25,16 +26,19 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( insert_route_points, ) from B05_wf2_Route.B05_wf2_Route_Schema import ( + GRADE_PERCENT_FIELDS, ContourIntervalUpdateRequest, ContourIntervalUpdateResponse, RouteConfirmResponse, RouteLatestResponse, RouteSolveRequest, RouteSolveResponse, + normalize_grade_percent, ) from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( create_longitudinal_section, delete_sections_for_route, + get_latest_grade_options, get_latest_section_options, insert_cross_sections, ) @@ -78,6 +82,40 @@ def _section_options( ) +def _normalized_route_params(params: dict[str, Any] | None) -> dict[str, Any] | None: + """복원 응답의 경사 값을 퍼센트로 맞춘다(과거 비율 저장분 자동 환산).""" + if not params: + return params + options = params.get("options") + if not isinstance(options, dict): + return params + return { + **params, + "options": { + **options, + **{ + key: normalize_grade_percent(options[key]) + for key in GRADE_PERCENT_FIELDS + if key in options + }, + }, + } + + +def _grade_options( + request: RouteSolveRequest, stored_grade_options: dict[str, Any] | None +) -> GradeDesignOptions: + """계획선 기준도 요청 값 → DB 저장 옵션 → config 순으로 결정한다.""" + return resolve_grade_options( + request.grade_class, + terrain_type=request.terrain_type, + paved=request.paved, + main_direction=request.main_direction, + requested=request.grade_options(), + stored=stored_grade_options, + ) + + @router.post("/{project_id}/route/solve", response_model=RouteSolveResponse) async def solve_route( project_id: UUID, request: RouteSolveRequest @@ -97,6 +135,7 @@ async def solve_route( "cross_half_width_m": request.cross_half_width_m, "cross_sample_interval_m": request.cross_sample_interval_m, "long_sample_interval_m": request.long_sample_interval_m, + **request.grade_options(), } log_b05_debug( logger, @@ -219,11 +258,13 @@ async def solve_route( # 종횡단 생성 실패는 저장된 경로를 무효화하지 않으므로 비치명적으로 처리한다. longitudinal_length_m: float | None = None cross_section_count: int | None = None + grade_summary: dict[str, Any] | None = None try: crs_epsg = await get_surface_crs_epsg( connection, project_id, request.surface_model_id ) stored_options = await get_latest_section_options(connection, project_id) + stored_grade_options = await get_latest_grade_options(connection, project_id) sections = await asyncio.to_thread( run_section_generation, project_root, @@ -232,6 +273,10 @@ async def solve_route( request.method, request.smooth, options=_section_options(request, stored_options), + grade_options=_grade_options(request, stored_grade_options), + # B05 폼이 계획선 입력의 authoritative 소스이므로 요청 값을 그대로 + # 저장한다(사용자가 값을 지우면 저장분도 지워져 법정 기본값으로 복귀). + grade_overrides=request.grade_options(), crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, ) await connection.begin() @@ -256,6 +301,7 @@ async def solve_route( raise longitudinal_length_m = sections["longitudinal"]["data"]["length_m"] cross_section_count = len(sections["cross_sections"]) + grade_summary = sections["longitudinal"]["data"].get("grade_summary") except Exception: logger.exception( "B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s", @@ -272,6 +318,7 @@ async def solve_route( route_data_path=design["route_data_path"], longitudinal_length_m=longitudinal_length_m, cross_section_count=cross_section_count, + grade_summary=grade_summary, ) except LookupError as exc: async with pool.acquire() as connection, connection.cursor() as cursor: @@ -349,7 +396,9 @@ async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONRespo route=latest, route_points=route_points, surface_params=surface_params, - route_params=route_stage.get("params") if route_stage else None, + route_params=_normalized_route_params( + route_stage.get("params") if route_stage else None + ), ) except Exception: logger.exception("B05 최신 경로 조회 실패: project_id=%s", project_id) diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index baf7652c..8c8d44ec 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -4,7 +4,37 @@ from typing import Any from pydantic import BaseModel, ConfigDict, Field, model_validator -from config.config_system import ROUTE_GRADE_CLASSES +from config.config_system import ( + GRADE_MAIN_DIRECTIONS, + GRADE_TERRAIN_TYPES, + ROUTE_GRADE_CLASSES, +) + +# 경사 입력은 화면·API·DB 모두 **퍼센트(%)** 로 통일한다(비율로 쓰던 과거와 다름). +# 엔진 진입점에서만 100으로 나눠 비율로 바꾼다. +GRADE_PERCENT_FIELDS = ( + "max_uphill_grade", + "max_downhill_grade", + "min_uphill_grade", + "min_downhill_grade", +) +# 과거 버전은 같은 필드를 비율(0.14 = 14%)로 저장했다. 임도 종단기울기를 1% 이하로 +# 두는 경우는 없으므로, 1 이하 값은 레거시 비율로 보고 퍼센트로 환산한다. +LEGACY_GRADE_RATIO_MAX = 1.0 + + +def normalize_grade_percent(value: Any) -> Any: + """경사 값을 퍼센트로 정규화한다(레거시 비율 자동 환산).""" + if value is None: + return None + try: + number = float(value) + except (TypeError, ValueError): + return value + if 0.0 < number <= LEGACY_GRADE_RATIO_MAX: + # 0.14 * 100 = 14.000000000000002 같은 잔여 오차를 남기지 않는다. + return round(number * 100.0, 6) + return number class RoutePoint(BaseModel): @@ -48,10 +78,11 @@ class RouteSolveRequest(BaseModel): grade_class: str = Field(default="trunk") paved: bool = Field(default=False) min_curve_radius_m: float | None = None - max_uphill_grade: float | None = None - max_downhill_grade: float | None = None - min_uphill_grade: float | None = None - min_downhill_grade: float | None = None + # 경로탐색 경사 제약 (단위: %). 과거 비율 저장분은 검증 단계에서 자동 환산된다. + max_uphill_grade: float | None = Field(default=None, ge=0, le=100) + max_downhill_grade: float | None = Field(default=None, ge=0, le=100) + min_uphill_grade: float | None = Field(default=None, ge=0, le=100) + min_downhill_grade: float | None = Field(default=None, ge=0, le=100) weights: dict[str, float] | None = None allow_avoid_pass_through: bool = Field(default=False) station_interval_m: float | None = Field(default=None, gt=0) @@ -59,14 +90,44 @@ class RouteSolveRequest(BaseModel): cross_sample_interval_m: float | None = Field(default=None, gt=0) long_sample_interval_m: float | None = Field(default=None, gt=0) + # 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다. + terrain_type: str = Field(default="normal", description="지형 구분 (normal/special)") + main_direction: str = Field( + default="auto", description="주 진행방향 (auto/ascending/descending/none)" + ) + max_grade_pct: float | None = Field(default=None, gt=0) + min_vertical_radius_m: float | None = Field(default=None, gt=0) + min_tangent_length_m: float | None = Field(default=None, gt=0) + balance_segment_length_m: float | None = Field(default=None, gt=0) + start_elevation_offset_m: float | None = None + end_elevation_offset_m: float | None = None + @model_validator(mode="after") def validate_choices(self) -> "RouteSolveRequest": if self.grade_class not in ROUTE_GRADE_CLASSES: raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.") if self.algorithm not in ("dijkstra", "ridge_valley"): raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.") + if self.terrain_type not in GRADE_TERRAIN_TYPES: + raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.") + if self.main_direction not in GRADE_MAIN_DIRECTIONS: + raise ValueError(f"주 진행방향은 {GRADE_MAIN_DIRECTIONS} 중 하나여야 합니다.") + # 화면·DB에 남아 있던 비율 표기를 퍼센트로 맞춰 이후 단계를 단일 단위로 만든다. + for field_name in GRADE_PERCENT_FIELDS: + setattr(self, field_name, normalize_grade_percent(getattr(self, field_name))) return self + def grade_options(self) -> dict[str, Any]: + """계획선 엔진에 넘길 요청 측 재정의 값(사용자가 비우면 None).""" + return { + "max_grade_pct": self.max_grade_pct, + "min_vertical_radius_m": self.min_vertical_radius_m, + "min_tangent_length_m": self.min_tangent_length_m, + "balance_segment_length_m": self.balance_segment_length_m, + "start_elevation_offset_m": self.start_elevation_offset_m, + "end_elevation_offset_m": self.end_elevation_offset_m, + } + def points_data(self) -> dict[str, Any]: return { "bp": self.bp.model_dump(), @@ -80,6 +141,8 @@ class RouteSolveRequest(BaseModel): return { "grade_class": self.grade_class, "paved": self.paved, + "terrain_type": self.terrain_type, + "main_direction": self.main_direction, "min_curve_radius_m": self.min_curve_radius_m, "max_uphill_grade": self.max_uphill_grade, "max_downhill_grade": self.max_downhill_grade, @@ -119,6 +182,8 @@ class RouteSolveResponse(BaseModel): # 종횡단 생성 실패 시 None (경로 자체는 저장됨) longitudinal_length_m: float | None = None cross_section_count: int | None = None + # 계획선 산출 실패 시 None (종횡단·경로는 저장됨) + grade_summary: dict[str, Any] | None = None class RouteConfirmResponse(BaseModel): diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 315b6efa..60005129 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -167,6 +167,14 @@ export async function renderB05Route(root: HTMLElement): Promise { stationInterval: next.route_params?.station_interval_m ?? undefined, crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined, longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined, + terrainType: options.terrain_type as RoutePanelValues["terrainType"] | undefined, + mainDirection: options.main_direction as RoutePanelValues["mainDirection"] | undefined, + maxGradePct: next.route_params?.max_grade_pct ?? undefined, + minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined, + minTangentLength: next.route_params?.min_tangent_length_m ?? undefined, + balanceSegmentLength: next.route_params?.balance_segment_length_m ?? undefined, + startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined, + endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined, }); viewer.markers.setPoints(restorePoints(next)); } @@ -265,11 +273,21 @@ export async function renderB05Route(root: HTMLElement): Promise { cross_half_width_m: null, cross_sample_interval_m: values.crossSampleInterval, long_sample_interval_m: values.longSampleInterval, + terrain_type: values.terrainType, + main_direction: values.mainDirection, + max_grade_pct: values.maxGradePct, + min_vertical_radius_m: values.minVerticalRadius, + min_tangent_length_m: values.minTangentLength, + balance_segment_length_m: values.balanceSegmentLength, + start_elevation_offset_m: values.startElevationOffset, + end_elevation_offset_m: values.endElevationOffset, }); renderLatest(await fetchLatestRoute(activeProjectId)); await restoreSections(solved.route_id); if (solved.cross_section_count === null) { showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error"); + } else if (solved.grade_summary === null) { + showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error"); } else { showToast("최적 경로 계산이 완료되었습니다.", "success"); } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index 1409aa42..73bd9dd9 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -16,8 +16,30 @@ export interface RoutePanelValues { stationInterval: number | null; crossSampleInterval: number | null; longSampleInterval: number | null; + terrainType: "normal" | "special"; + mainDirection: "auto" | "ascending" | "descending" | "none"; + maxGradePct: number | null; + minVerticalRadius: number | null; + minTangentLength: number | null; + balanceSegmentLength: number | null; + startElevationOffset: number | null; + endElevationOffset: number | null; } +/** + * 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값. + * 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다. + * 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다. + */ +const PROFILE_CRITERIA: Record< + RoutePanelValues["gradeClass"], + { speed: number; grade: Record; radius: number } +> = { + trunk: { speed: 40, grade: { normal: 7, special: 10 }, radius: 450 }, + branch: { speed: 30, grade: { normal: 8, special: 12 }, radius: 250 }, + work: { speed: 20, grade: { normal: 9, special: 14 }, radius: 100 }, +}; + interface PanelCallbacks { onSolve: () => void; onConfirm: () => void; @@ -195,10 +217,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) { gradeLabel.className = "b05-route__field"; gradeLabel.append(document.createTextNode("임도 등급"), gradeClass); const minCurveRadius = numberField("최소 곡선반경 (m)"); - const maxUphillGrade = numberField("오르막 경사 상한"); - const maxDownhillGrade = numberField("내리막 경사 상한"); - const minUphillGrade = numberField("오르막 경사 하한"); - const minDownhillGrade = numberField("내리막 경사 하한"); + const maxUphillGrade = numberField("오르막 경사 상한 (%)"); + const maxDownhillGrade = numberField("내리막 경사 상한 (%)"); + const minUphillGrade = numberField("오르막 경사 하한 (%)"); + const minDownhillGrade = numberField("내리막 경사 하한 (%)"); const paved = checkbox("포장 임도", false); const avoidPass = checkbox("회피구역 통과 허용", false); const details = document.createElement("details"); @@ -234,6 +256,71 @@ export function createRoutePanel(callbacks: PanelCallbacks) { longSampleInterval.wrapper, ); + const gradeLine = section("계획선(시공계획고) 설계"); + const terrainType = document.createElement("select"); + terrainType.innerHTML = + ''; + const terrainLabel = document.createElement("label"); + terrainLabel.className = "b05-route__field"; + terrainLabel.append(document.createTextNode("지형 구분"), terrainType); + // 역기울기(5%) 상한을 어느 방향에 적용할지 결정한다. 계곡 횡단·능선 통과처럼 + // 주 진행방향이 없는 노선에 역기울기를 걸면 지형을 따라갈 수 없어진다. + const mainDirection = document.createElement("select"); + mainDirection.innerHTML = + '' + + '' + + '' + + ''; + const directionLabel = document.createElement("label"); + directionLabel.className = "b05-route__field"; + directionLabel.append(document.createTextNode("주 진행방향"), mainDirection); + const maxGradePct = numberField("최대 종단기울기 (%)"); + const minVerticalRadius = numberField("종단곡선 최소 반경 (m)"); + const minTangentLength = numberField("최소 직선 길이 (m)"); + const balanceSegmentLength = numberField("균형 구역 길이 (m, 비우면 전체)"); + const startElevationOffset = numberField("시점 계획고 조정 (m)"); + const endElevationOffset = numberField("종점 계획고 조정 (m)"); + const criteriaNote = document.createElement("p"); + criteriaNote.className = "b05-route__note"; + const gradeAdvanced = document.createElement("details"); + const gradeSummary = document.createElement("summary"); + gradeSummary.textContent = "기준값 직접 지정"; + gradeAdvanced.append( + gradeSummary, + maxGradePct.wrapper, + minVerticalRadius.wrapper, + minTangentLength.wrapper, + startElevationOffset.wrapper, + endElevationOffset.wrapper, + ); + const gradeHelp = document.createElement("details"); + gradeHelp.innerHTML = + "계획선이란?

공사 후 노면이 될 높이입니다. 직선과 종단곡선만으로 구성되며, " + + "절토량과 성토량이 균형을 이루도록(적분값 0) 자동 산출됩니다.

"; + gradeLine.body.append( + terrainLabel, + directionLabel, + balanceSegmentLength.wrapper, + criteriaNote, + gradeAdvanced, + gradeHelp, + ); + + /** 등급·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */ + function syncCriteria(): void { + const criteria = PROFILE_CRITERIA[gradeClass.value as RoutePanelValues["gradeClass"]]; + const terrain = terrainType.value as RoutePanelValues["terrainType"]; + maxGradePct.placeholder = String(criteria.grade[terrain]); + minVerticalRadius.placeholder = String(criteria.radius); + minTangentLength.placeholder = "20"; + criteriaNote.textContent = + `설계속도 ${criteria.speed}km/h 기준 — 종단기울기 ${criteria.grade[terrain]}% 이하, ` + + `종단곡선 반경 ${criteria.radius}m 이상. 비워두면 이 기준이 적용됩니다.`; + } + gradeClass.addEventListener("change", syncCriteria); + terrainType.addEventListener("change", syncCriteria); + syncCriteria(); + const result = section("경로 설계 산출 결과"); const stale = document.createElement("span"); stale.className = "b05-route__stale"; @@ -264,6 +351,14 @@ export function createRoutePanel(callbacks: PanelCallbacks) { stationInterval, crossSampleInterval, longSampleInterval, + terrainType, + mainDirection, + maxGradePct, + minVerticalRadius, + minTangentLength, + balanceSegmentLength, + startElevationOffset, + endElevationOffset, ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); root.append( @@ -272,6 +367,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { selected.root, conditions.root, sectionOptions.root, + gradeLine.root, result.root, actionRow, ); @@ -294,6 +390,14 @@ export function createRoutePanel(callbacks: PanelCallbacks) { stationInterval: parseOptional(stationInterval), crossSampleInterval: parseOptional(crossSampleInterval), longSampleInterval: parseOptional(longSampleInterval), + terrainType: terrainType.value as RoutePanelValues["terrainType"], + mainDirection: mainDirection.value as RoutePanelValues["mainDirection"], + maxGradePct: parseOptional(maxGradePct), + minVerticalRadius: parseOptional(minVerticalRadius), + minTangentLength: parseOptional(minTangentLength), + balanceSegmentLength: parseOptional(balanceSegmentLength), + startElevationOffset: parseOptional(startElevationOffset), + endElevationOffset: parseOptional(endElevationOffset), }; }, restore(values: Partial) { @@ -312,6 +416,19 @@ export function createRoutePanel(callbacks: PanelCallbacks) { crossSampleInterval.value = String(values.crossSampleInterval); if (values.longSampleInterval != null) longSampleInterval.value = String(values.longSampleInterval); + if (values.terrainType) terrainType.value = values.terrainType; + if (values.mainDirection) mainDirection.value = values.mainDirection; + if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct); + if (values.minVerticalRadius != null) + minVerticalRadius.value = String(values.minVerticalRadius); + if (values.minTangentLength != null) minTangentLength.value = String(values.minTangentLength); + if (values.balanceSegmentLength != null) + balanceSegmentLength.value = String(values.balanceSegmentLength); + if (values.startElevationOffset != null) + startElevationOffset.value = String(values.startElevationOffset); + if (values.endElevationOffset != null) + endElevationOffset.value = String(values.endElevationOffset); + syncCriteria(); }, setSelected(point: PlacedRoutePoint | null) { selected.root.hidden = !point; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 7026ce4a..4585997d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -11,6 +11,45 @@ import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; const HORIZONTAL_SCROLLBAR_HEIGHT = 16; +const BALANCE_BAR_HEIGHT = 24; + +/** 계획선 절·성토 균형 결과를 종단도 위에 한 줄 요약으로 보여준다. */ +function renderBalanceSummary(data: LongitudinalSection): HTMLElement | null { + const profile = data.design_profiles?.[0]; + if (!profile) return null; + const bar = document.createElement("div"); + bar.className = "b05-route-profile__balance"; + const summary = profile.summary; + const entries: Array<[string, string, string?]> = [ + ["절토", `${summary.cut_area_m2.toFixed(1)} m²`, "cut"], + ["성토", `${summary.fill_area_m2.toFixed(1)} m²`, "fill"], + ["균형오차", `${summary.balance_error_m2.toFixed(2)} m²${summary.balanced ? "" : " (근사)"}`], + ["최대 종단기울기", `${summary.max_grade_pct.toFixed(2)} %`], + ["종단곡선", `${summary.vertical_curve_count} 개`], + ]; + if (summary.balance_segment_count > 1) { + entries.push(["균형구역", `${summary.balance_segment_count} 개`]); + } + if (summary.suggested_elevation_offset_m !== null) { + entries.push(["시·종점 조정 제안", `${summary.suggested_elevation_offset_m.toFixed(1)} m`]); + } + entries.forEach(([label, value, tone]) => { + const item = document.createElement("span"); + item.className = `b05-route-profile__balance-item${tone ? ` is-${tone}` : ""}`; + const caption = document.createElement("em"); + caption.textContent = label; + item.append(caption, document.createTextNode(value)); + bar.append(item); + }); + if (summary.warnings.length) { + const warning = document.createElement("span"); + warning.className = "b05-route-profile__balance-warning"; + warning.textContent = `⚠ ${summary.warnings[0]}`; + warning.title = summary.warnings.join("\n"); + bar.append(warning); + } + return bar; +} function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection { return { @@ -44,24 +83,28 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) => function draw(): void { if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return; const availableWidth = Math.max(1, body.clientWidth - 30); - const height = Math.max(1, body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT); + const balance = renderBalanceSummary(detail.longitudinal); + const height = Math.max( + 1, + body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT - (balance ? BALANCE_BAR_HEIGHT : 0), + ); const minimumWidth = longitudinalMinimumWidth(detail.longitudinal, stationInterval); const width = Math.max(availableWidth, minimumWidth); lastWidth = body.clientWidth; lastHeight = height; - body.replaceChildren( - createLongitudinalProfile( - normalizedLongitudinal(detail.longitudinal), - selectedStationId, - 1, - undefined, - onSelectStation, - stationInterval, - width, - height, - minimumWidth, - ), + const chart = createLongitudinalProfile( + normalizedLongitudinal(detail.longitudinal), + selectedStationId, + 1, + undefined, + onSelectStation, + stationInterval, + width, + height, + minimumWidth, + detail.longitudinal.design_profiles ?? [], ); + body.replaceChildren(...(balance ? [balance, chart] : [chart])); } const resizeObserver = new ResizeObserver(() => { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 711d87c3..57b25cfd 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -242,3 +242,50 @@ color: var(--color-text-body); font-size: var(--text-caption); } + +/* 계획선 설계 폼 안내문 */ +.b05-route__note { + margin: 0; + color: var(--color-text-muted, var(--color-plum-velvet)); + font-size: var(--text-caption); + line-height: 1.4; +} + +/* 종단면도 상단 절·성토 균형 지표 바 */ +.b05-route-profile__balance { + display: flex; + flex-wrap: nowrap; + gap: var(--spacing-16); + align-items: center; + height: 24px; + padding: 0 var(--spacing-8); + overflow-x: auto; + overflow-y: hidden; + font-size: var(--text-caption); + white-space: nowrap; +} + +.b05-route-profile__balance-item { + display: inline-flex; + gap: var(--spacing-8); + align-items: center; +} + +.b05-route-profile__balance-item em { + color: var(--color-text-muted, var(--color-plum-velvet)); + font-style: normal; +} + +.b05-route-profile__balance-item.is-cut em { + color: rgb(220 38 38); +} + +.b05-route-profile__balance-item.is-fill em { + color: rgb(37 99 235); +} + +.b05-route-profile__balance-warning { + overflow: hidden; + color: rgb(180 83 9); + text-overflow: ellipsis; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 70c99638..1b486f42 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -64,10 +64,55 @@ export interface SectionStation { frame: { left_xy: [number, number] }; } +/** 계획선 샘플 (계획고와 지반고, 그 차이). */ +export interface DesignProfileSample { + chainage_m: number; + elevation_m: number; + ground_elevation_m: number; + difference_m: number; +} + +/** 절·성토 균형을 판정하는 구역 단위 결과. */ +export interface DesignProfileSegment { + index: number; + start_chainage_m: number; + end_chainage_m: number; + cut_area_m2: number; + fill_area_m2: number; + balance_error_m2: number; +} + +export interface DesignProfileSummary { + cut_area_m2: number; + fill_area_m2: number; + balance_error_m2: number; + max_grade_pct: number; + vertical_curve_count: number; + pvi_count: number; + balance_segment_count: number; + balanced: boolean; + main_direction: string; + suggested_elevation_offset_m: number | null; + warnings: string[]; +} + +/** + * 종단 계획선. 횡단 설계 기반 계획선이 추가될 수 있어 배열로 전달된다. + */ +export interface DesignProfile { + id: string; + name: string; + basis: string; + samples: DesignProfileSample[]; + balance_segments: DesignProfileSegment[]; + summary: DesignProfileSummary; +} + export interface LongitudinalSection { length_m: number; samples: SectionSample[]; stations: SectionStation[]; + design_profiles?: DesignProfile[]; } export interface CrossSection extends SectionStation { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index fe04565c..79535892 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -86,6 +86,36 @@ async def get_latest_section_options( return options if isinstance(options, dict) else None +async def get_latest_grade_options( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """최신 종단면 data에 저장된 계획선 **사용자 입력값**을 반환한다 (없으면 None). + + 해석이 끝난 기준값(`grade_options`)이 아니라 사용자가 명시 입력한 값 + (`grade_overrides`)만 돌려준다. 전자를 폴백으로 되쓰면 등급·지형 구분을 바꿔도 + 옛 기본값이 법정값을 이겨 갱신되지 않는다. + """ + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT data + FROM longitudinal_sections + WHERE project_id = %s + ORDER BY id DESC + LIMIT 1 + """, + (str(project_id),), + ) + row = await cursor.fetchone() + if not row or not row[0]: + return None + data = row[0] + if isinstance(data, str): + data = json.loads(data) + overrides = data.get("grade_overrides") if isinstance(data, dict) else None + return overrides if isinstance(overrides, dict) else None + + async def get_route_generation_source( connection: aiomysql.Connection, project_id: UUID, route_id: int ) -> dict[str, Any] | None: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 85c4fed2..704f7af5 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -1,6 +1,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossSection, + DesignProfile, LongitudinalSection, SectionDetailResponse, SectionSample, @@ -112,6 +113,47 @@ export function longitudinalMinimumWidth( return LONG_PAD.left + LONG_PAD.right + Math.max(1, data.stations.length) * labelWidth; } +/** + * 계획선과 지반선 사이를 절토(계획고 < 지반고)·성토 구간으로 나눠 음영을 그린다. + * 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다. + */ +function appendCutFillBands( + svg: SVGSVGElement, + profile: DesignProfile, + x: (chainage: number) => number, + planY: (index: number) => number, + groundY: (index: number) => number, +): void { + const samples = profile.samples; + let start = 0; + const flush = (end: number): void => { + if (end - start < 1) return; + const sign = samples[start].difference_m; + if (Math.abs(sign) < 1e-9) return; + const top: string[] = []; + const bottom: string[] = []; + for (let index = start; index <= end; index += 1) { + top.push(`${x(samples[index].chainage_m)},${planY(index)}`); + bottom.unshift(`${x(samples[index].chainage_m)},${groundY(index)}`); + } + svg.append( + svgElement("polygon", { + points: [...top, ...bottom].join(" "), + class: `b06-chart__band b06-chart__band--${sign < 0 ? "cut" : "fill"}`, + }), + ); + }; + for (let index = 1; index < samples.length; index += 1) { + const previous = samples[index - 1].difference_m; + const current = samples[index].difference_m; + if (previous === 0 || current === 0 || Math.sign(previous) !== Math.sign(current)) { + flush(index); + start = index; + } + } + flush(samples.length - 1); +} + export function createLongitudinalProfile( data: LongitudinalSection, selectedStationId: string | null, @@ -122,6 +164,7 @@ export function createLongitudinalProfile( widthPx = LONG_WIDTH, heightPx = LONG_HEIGHT, minimumWidthPx = widthPx, + designProfiles: DesignProfile[] = [], ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -141,7 +184,10 @@ export function createLongitudinalProfile( svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); - const elevations = samples.map((sample) => sample.elevation_m); + // 계획선이 지반선 밖으로 나가도 잘리지 않도록 세로 범위에 함께 반영한다. + const elevations = samples + .map((sample) => sample.elevation_m) + .concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m))); const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations); const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; @@ -177,6 +223,32 @@ export function createLongitudinalProfile( ); } + // 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다. + const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration); + for (const profile of designProfiles) { + if (profile.samples.length < 2) continue; + appendCutFillBands( + svg, + profile, + x, + (index) => toY(profile.samples[index].elevation_m), + (index) => toY(profile.samples[index].ground_elevation_m), + ); + if (profile.balance_segments.length > 1) { + for (const segment of profile.balance_segments.slice(1)) { + svg.append( + svgElement("line", { + x1: x(segment.start_chainage_m), + y1: LONG_PAD.top, + x2: x(segment.start_chainage_m), + y2: heightPx - LONG_PAD.bottom, + class: "b06-chart__balance-boundary", + }), + ); + } + } + } + for (const station of data.stations) { const stationX = x(station.chainage_m); const selected = station.station_id === selectedStationId; @@ -221,6 +293,17 @@ export function createLongitudinalProfile( return `${x(sample.chainage_m ?? 0)},${y(elevated)}`; }) .join(" "); + for (const profile of designProfiles) { + if (profile.samples.length < 2) continue; + svg.append( + svgElement("polyline", { + points: profile.samples + .map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`) + .join(" "), + class: "b06-chart__design-profile", + }), + ); + } svg.append( svgElement("polyline", { points, class: "b06-chart__profile" }), svgElement("line", { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index f01181b8..6dac0848 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -298,6 +298,34 @@ stroke-width: 2.4; } +/* 종단 계획선(시공계획고): 지반선과 구분되도록 파선 + 강조색 */ +.b06-chart__design-profile { + fill: none; + stroke: var(--color-royal-amethyst); + stroke-width: 2.2; + stroke-dasharray: 8 4; +} + +/* 절토(계획고가 지반고보다 낮음) / 성토 구간 음영 */ +.b06-chart__band { + stroke: none; +} + +.b06-chart__band--cut { + fill: rgb(220 38 38 / 14%); +} + +.b06-chart__band--fill { + fill: rgb(37 99 235 / 14%); +} + +.b06-chart__balance-boundary { + stroke: var(--color-royal-amethyst); + stroke-width: 1.2; + stroke-dasharray: 3 3; + opacity: 0.55; +} + .b06-chart__station { cursor: pointer; } diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts index 3e7bccc5..6fecd3d9 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts @@ -389,6 +389,17 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const confirmActions = document.createElement("div"); confirmActions.className = "b07-drawing-actions"; confirmActions.append(confirmButton); + + // TODO: [임시 테스트용] B08 강제 이동 버튼 (테스트 완료 후 제거 예정) + const tempB08Btn = createButton({ + label: "[임시] B08 이동", + variant: "outlined", + onClick: () => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[5]); + }, + }); + confirmActions.append(tempB08Btn); + drawingPanel.append(confirmActions); const layout = createWorkflowLayout({ diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_Template.ts b/B08_wf5_Quantity/B08_wf5_Quantity_Template.ts new file mode 100644 index 00000000..e64580e5 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_Template.ts @@ -0,0 +1,2068 @@ +/* ============================================================================= + * B08_wf5_Quantity_Template.ts + * 5차 워크플로우 (수량 산출) 엑셀 내역서 연동 템플릿 항목 정의 + * + * 엑셀 내역서(2025년 산불진화임도...)의 전체 145개 수량집계표 템플릿 완벽 매핑. + * 이전 단계(B07 등) 및 수식에 의해 자동 조달될 입력값(raw_qty, calc_qty 등)은 비워둔(null) 상태입니다. + * ========================================================================== */ + +export type QuantityCategory = + "earthwork" | "structure" | "revegetation" | "equipment" | "waste" | "material" | "labor"; + +export interface QuantityItemTemplate { + id: string; + item_no: string; // 호표 (예: "1", "2") + category: QuantityCategory; // 공종 분류 + name: string; // 명칭 + spec: string; // 규격 + unit: string; // 단위 + raw_qty: number | null; // B07 등 이전 설계 산출 수량 (비어있음) + allowance_rate: number; // 할증률 / 수량 보정 비율 (기본 1.0) + calc_qty: number | null; // 최종 산출 수량 (raw_qty * allowance_rate 또는 수식 계산) + formula_desc: string; // 수식 및 산출 근거 템플릿 표현식 + excel_ref_sheet: string; // 연동될 엑셀 시트명 + code_ref?: string; // 일위대가/노무/재료/경비 코드 +} + +export const QUANTITY_CATEGORY_LABELS: Record = { + earthwork: { ko: "1. 단가산출 근거 (40종)", en: "1. Unit Price Calcs" }, + structure: { ko: "2. 일위대가 구조물/배수 (25종)", en: "2. Structure & Drainage" }, + revegetation: { ko: "3. 녹화 및 환경 (37종)", en: "3. Revegetation & Misc" }, + equipment: { ko: "4. 중기 사용시간 (24종)", en: "4. Heavy Machinery Hours" }, + waste: { ko: "5. 일식 및 폐기물 (5종)", en: "5. Waste & Lump Sum" }, + material: { ko: "3. 자재 및 녹화 (37종)", en: "3. Materials" }, + labor: { ko: "6. 노무비 인력 (14종)", en: "6. Labor Personnel" }, +}; + +export const INITIAL_QUANTITY_TEMPLATES: QuantityItemTemplate[] = [ + { + id: "DG-001", + item_no: "제 1호표", + category: "earthwork", + name: "콘크리트믹서사용", + spec: "0.45 m3", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('콘크리트믹서사용')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D00083", + }, + { + id: "DG-002", + item_no: "제 2호표", + category: "earthwork", + name: "고임돌채집", + spec: "굴삭기0.7m3", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('고임돌채집')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D00091", + }, + { + id: "DG-003", + item_no: "제 3호표", + category: "earthwork", + name: "막자갈(파쇄석)채집", + spec: "", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('막자갈(파쇄석)채집')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D00408", + }, + { + id: "DG-004", + item_no: "제 4호표", + category: "earthwork", + name: "채움 콘크리트", + spec: "기계비빔", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('채움 콘크리트')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D00492", + }, + { + id: "DG-005", + item_no: "제 5호표", + category: "earthwork", + name: "단끊기", + spec: "절취없음", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('단끊기')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D00794", + }, + { + id: "DG-006", + item_no: "제 6호표", + category: "earthwork", + name: "막자갈채우기", + spec: "150mm내외", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('막자갈채우기')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D00867", + }, + { + id: "DG-007", + item_no: "제 7호표", + category: "earthwork", + name: "채움CON'C", + spec: "기계비빔", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('채움CON'C')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D00918", + }, + { + id: "DG-008", + item_no: "제 8호표", + category: "earthwork", + name: "고임돌채집", + spec: "굴삭기0.2m3", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('고임돌채집')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01030", + }, + { + id: "DG-009", + item_no: "제 9호표", + category: "earthwork", + name: "떼 하차비", + spec: "", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('떼 하차비')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01129", + }, + { + id: "DG-010", + item_no: "제 10호표", + category: "earthwork", + name: "평떼붙이기", + spec: "", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('평떼붙이기')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01329", + }, + { + id: "DG-011", + item_no: "제 11호표", + category: "earthwork", + name: "레미콘타설", + spec: "무근(장비)", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('레미콘타설')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01336", + }, + { + id: "DG-012", + item_no: "제 12호표", + category: "earthwork", + name: "유로폼 설치 및 해체", + spec: "간단", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('유로폼 설치 및 해체')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01351", + }, + { + id: "DG-013", + item_no: "제 13호표", + category: "earthwork", + name: "유로폼 설치 및 해체", + spec: "보통", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('유로폼 설치 및 해체')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01352", + }, + { + id: "DG-014", + item_no: "제 14호표", + category: "earthwork", + name: "콘크리포장(T=20cm)포설", + spec: "인력포설", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('콘크리포장(T=20cm)포설')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01354", + }, + { + id: "DG-015", + item_no: "제 15호표", + category: "earthwork", + name: "파쇄석 채집", + spec: "L3=35cm내외", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('파쇄석 채집')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01448", + }, + { + id: "DG-016", + item_no: "제 16호표", + category: "earthwork", + name: "구조물터파기(토사)", + spec: "굴착기0.6m3", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('구조물터파기(토사)')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01456", + }, + { + id: "DG-017", + item_no: "제 17호표", + category: "earthwork", + name: "구조물되메우기", + spec: "굴착기0.6m3", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('구조물되메우기')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01474", + }, + { + id: "DG-018", + item_no: "제 18호표", + category: "earthwork", + name: "구조물잔토처리", + spec: "굴착기0.6m3", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('구조물잔토처리')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01475", + }, + { + id: "DG-019", + item_no: "제 19호표", + category: "earthwork", + name: "제근", + spec: "밀림(90m3/ha이상)", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('제근')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01476", + }, + { + id: "DG-020", + item_no: "제 20호표", + category: "earthwork", + name: "토사절취", + spec: "굴삭기 0.7m3", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('토사절취')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01477", + }, + { + id: "DG-021", + item_no: "제 21호표", + category: "earthwork", + name: "암(연암)절취", + spec: "굴삭기07+브레카", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('암(연암)절취')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01478", + }, + { + id: "DG-022", + item_no: "제 22호표", + category: "earthwork", + name: "옆도랑파기(토사)", + spec: "굴삭기 0.4m3", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('옆도랑파기(토사)')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01479", + }, + { + id: "DG-023", + item_no: "제 23호표", + category: "earthwork", + name: "옆도랑파기(연암)", + spec: "대형브레카(굴삭기0.7㎥)", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('옆도랑파기(연암)')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01480", + }, + { + id: "DG-024", + item_no: "제 24호표", + category: "earthwork", + name: "도자운반-토사", + spec: "L=46m", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('도자운반-토사')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01481", + }, + { + id: "DG-025", + item_no: "제 25호표", + category: "earthwork", + name: "도자운반-연암", + spec: "L=44m", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('도자운반-연암')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01482", + }, + { + id: "DG-026", + item_no: "제 26호표", + category: "earthwork", + name: "덤프운반-토사", + spec: "L=85m", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('덤프운반-토사')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01483", + }, + { + id: "DG-027", + item_no: "제 27호표", + category: "earthwork", + name: "덤프운반-연암", + spec: "L=82m", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('덤프운반-연암')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01484", + }, + { + id: "DG-028", + item_no: "제 28호표", + category: "earthwork", + name: "성토사면다짐", + spec: "진동콤팩터", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('성토사면다짐')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01485", + }, + { + id: "DG-029", + item_no: "제 29호표", + category: "earthwork", + name: "노면 고르기", + spec: "", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('노면 고르기')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01486", + }, + { + id: "DG-030", + item_no: "제 30호표", + category: "earthwork", + name: "사토운반", + spec: "L=409m", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('사토운반')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01487", + }, + { + id: "DG-031", + item_no: "제 31호표", + category: "earthwork", + name: "수축줄눈(컷팅)", + spec: "콘크리트포장", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('수축줄눈(컷팅)')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01488", + }, + { + id: "DG-032", + item_no: "제 32호표", + category: "earthwork", + name: "낙석방지책(표준부)", + spec: "H=2.5m,B=2.0m", + unit: "경간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('낙석방지책(표준부)')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01489", + }, + { + id: "DG-033", + item_no: "제 33호표", + category: "earthwork", + name: "낙석방지책(단부)", + spec: "H=2.5m,B=2.0m", + unit: "경간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('낙석방지책(단부)')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01490", + }, + { + id: "DG-034", + item_no: "제 34호표", + category: "earthwork", + name: "구조물헐기", + spec: "무근,30cm미만", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('구조물헐기')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01491", + }, + { + id: "DG-035", + item_no: "제 35호표", + category: "earthwork", + name: "씨뿌리기", + spec: "", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('씨뿌리기')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01492", + }, + { + id: "DG-036", + item_no: "제 36호표", + category: "earthwork", + name: "중기운반", + spec: "L=34.5km", + unit: "식", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('중기운반')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01493", + }, + { + id: "DG-037", + item_no: "제 37호표", + category: "earthwork", + name: "모래(대)운반", + spec: "L=103.3km", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('모래(대)운반')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01494", + }, + { + id: "DG-038", + item_no: "제 38호표", + category: "earthwork", + name: "자갈류(대)운반", + spec: "L=77.4km", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('자갈류(대)운반')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01495", + }, + { + id: "DG-039", + item_no: "제 39호표", + category: "earthwork", + name: "시멘트(대)운반", + spec: "L=77.4km", + unit: "대", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('시멘트(대)운반')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01496", + }, + { + id: "DG-040", + item_no: "제 40호표", + category: "earthwork", + name: "파쇄(굴림)석 대운반", + spec: "L=77.4km", + unit: "톤", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Design.calc('파쇄(굴림)석 대운반')", + excel_ref_sheet: "단가산출근거목록표", + code_ref: "D01497", + }, + { + id: "IW-001", + item_no: "제 1호표", + category: "structure", + name: "유로폼", + spec: "보통", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('유로폼')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B00011", + }, + { + id: "IW-002", + item_no: "제 2호표", + category: "structure", + name: "철근(현장)가공", + spec: "Type-Ⅰ-----아님", + unit: "ton", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('철근(현장)가공')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B00032", + }, + { + id: "IW-003", + item_no: "제 3호표", + category: "structure", + name: "모르타르배합", + spec: "1:3", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('모르타르배합')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B00048", + }, + { + id: "IW-004", + item_no: "제 4호표", + category: "structure", + name: "문양 거푸집", + spec: "", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('문양 거푸집')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B00233", + }, + { + id: "IW-005", + item_no: "제 5호표", + category: "structure", + name: "연결(이음)철근", + spec: "", + unit: "kg", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('연결(이음)철근')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B00287", + }, + { + id: "IW-006", + item_no: "제 6호표", + category: "structure", + name: "돌붙임", + spec: "메붙임,L3=35cm이하", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('돌붙임')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B00374", + }, + { + id: "IW-007", + item_no: "제 7호표", + category: "structure", + name: "돌붙임", + spec: "찰붙임,L3=55cm이하", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('돌붙임')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B00378", + }, + { + id: "IW-008", + item_no: "제 8호표", + category: "structure", + name: "파형강관부설", + spec: "D=800mm", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('파형강관부설')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B00437", + }, + { + id: "IW-009", + item_no: "제 9호표", + category: "structure", + name: "물구멍설치", + spec: "D=50mm", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('물구멍설치')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B00442", + }, + { + id: "IW-010", + item_no: "제 10호표", + category: "structure", + name: "유로폼", + spec: "간단", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('유로폼')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B01299", + }, + { + id: "IW-011", + item_no: "제 11호표", + category: "structure", + name: "철근(현장)조립", + spec: "Type-Ⅰ-----아님", + unit: "ton", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('철근(현장)조립')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B01582", + }, + { + id: "IW-012", + item_no: "제 12호표", + category: "structure", + name: "돌쌓기", + spec: "찰(켜)쌓기,L3=55cm이하", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('돌쌓기')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B01608", + }, + { + id: "IW-013", + item_no: "제 13호표", + category: "structure", + name: "돌쌓기(깬돌)", + spec: "메(골)쌓기,L3=55cm이하", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('돌쌓기(깬돌)')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B01680", + }, + { + id: "IW-014", + item_no: "제 14호표", + category: "structure", + name: "파형강관부설", + spec: "D=800mm", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('파형강관부설')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02119", + }, + { + id: "IW-015", + item_no: "제 15호표", + category: "structure", + name: "관보호공", + spec: "돌쌓기(ㅁ형)", + unit: "개소", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('관보호공')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02120", + }, + { + id: "IW-016", + item_no: "제 16호표", + category: "structure", + name: "기초CON'C", + spec: "T=50cm, B=70cm", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('기초CON'C')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02121", + }, + { + id: "IW-017", + item_no: "제 17호표", + category: "structure", + name: "돌기슭막이(고1.0)", + spec: "(찰쌓기,L3=45cm)", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('돌기슭막이(고1.0)')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02122", + }, + { + id: "IW-018", + item_no: "제 18호표", + category: "structure", + name: "돌조공(고0.5)", + spec: "(메쌓기,L3=45cm)", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('돌조공(고0.5)')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02123", + }, + { + id: "IW-019", + item_no: "제 19호표", + category: "structure", + name: "제형돌수로", + spec: "B=0.75m", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('제형돌수로')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02124", + }, + { + id: "IW-020", + item_no: "제 20호표", + category: "structure", + name: "돌붙임", + spec: "깬돌,메붙임 L3=35cm", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('돌붙임')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02125", + }, + { + id: "IW-021", + item_no: "제 21호표", + category: "structure", + name: "콘크리트포장", + spec: "T=20㎝", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('콘크리트포장')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02126", + }, + { + id: "IW-022", + item_no: "제 22호표", + category: "structure", + name: "포장거푸집", + spec: "T=20cm(1면기준)", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('포장거푸집')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02127", + }, + { + id: "IW-023", + item_no: "제 23호표", + category: "structure", + name: "L형옹벽측구", + spec: "H=1.05m", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('L형옹벽측구')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02128", + }, + { + id: "IW-024", + item_no: "제 24호표", + category: "structure", + name: "기초CON'C", + spec: "(0.4×0.4 H:0.6)", + unit: "개소", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('기초CON'C')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02129", + }, + { + id: "IW-025", + item_no: "제 25호표", + category: "structure", + name: "선떼(7급)붙이기", + spec: "(단끊기無;천단부)", + unit: "m", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= B07_Structure.calc('선떼(7급)붙이기')", + excel_ref_sheet: "일위대가목록표", + code_ref: "B02130", + }, + { + id: "EQ-001", + item_no: "1", + category: "equipment", + name: "트럭탑재형 크레인", + spec: "5톤", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('트럭탑재형 크레인')", + excel_ref_sheet: "중기목록표", + code_ref: "X00002", + }, + { + id: "EQ-002", + item_no: "2", + category: "equipment", + name: "굴삭기+브레카(0.7m3)", + spec: "", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('굴삭기+브레카(0.7m3)')", + excel_ref_sheet: "중기목록표", + code_ref: "X00004", + }, + { + id: "EQ-003", + item_no: "3", + category: "equipment", + name: "불도저(무한궤도)", + spec: "19톤", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('불도저(무한궤도)')", + excel_ref_sheet: "중기목록표", + code_ref: "X00008", + }, + { + id: "EQ-004", + item_no: "4", + category: "equipment", + name: "굴삭기(무한궤도)", + spec: "0.4㎥", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('굴삭기(무한궤도)')", + excel_ref_sheet: "중기목록표", + code_ref: "X00021", + }, + { + id: "EQ-005", + item_no: "5", + category: "equipment", + name: "굴삭기(무한궤도)", + spec: "0.7㎥", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('굴삭기(무한궤도)')", + excel_ref_sheet: "중기목록표", + code_ref: "X00022", + }, + { + id: "EQ-006", + item_no: "6", + category: "equipment", + name: "대형 브레이커", + spec: "0.7㎥", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('대형 브레이커')", + excel_ref_sheet: "중기목록표", + code_ref: "X00029", + }, + { + id: "EQ-007", + item_no: "7", + category: "equipment", + name: "유압식 진동콤팩터(굴삭기 부착용)", + spec: "0.7㎥", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('유압식 진동콤팩터(굴삭기 부착용)')", + excel_ref_sheet: "중기목록표", + code_ref: "X00032", + }, + { + id: "EQ-008", + item_no: "8", + category: "equipment", + name: "덤프트럭", + spec: "2.5톤", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('덤프트럭')", + excel_ref_sheet: "중기목록표", + code_ref: "X00060", + }, + { + id: "EQ-009", + item_no: "9", + category: "equipment", + name: "덤프트럭", + spec: "4.5톤", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('덤프트럭')", + excel_ref_sheet: "중기목록표", + code_ref: "X00061", + }, + { + id: "EQ-010", + item_no: "10", + category: "equipment", + name: "덤프트럭", + spec: "10.5톤", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('덤프트럭')", + excel_ref_sheet: "중기목록표", + code_ref: "X00063", + }, + { + id: "EQ-011", + item_no: "11", + category: "equipment", + name: "덤프트럭", + spec: "15톤", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('덤프트럭')", + excel_ref_sheet: "중기목록표", + code_ref: "X00064", + }, + { + id: "EQ-012", + item_no: "12", + category: "equipment", + name: "트럭트랙터및트레일러", + spec: "20톤", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('트럭트랙터및트레일러')", + excel_ref_sheet: "중기목록표", + code_ref: "X00127", + }, + { + id: "EQ-013", + item_no: "13", + category: "equipment", + name: "굴삭기(무한궤도)", + spec: "0.2㎥:할증120%", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('굴삭기(무한궤도)')", + excel_ref_sheet: "중기목록표", + code_ref: "X00268", + }, + { + id: "EQ-014", + item_no: "14", + category: "equipment", + name: "굴삭기(무한궤도)", + spec: "0.7㎥:할증120%", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('굴삭기(무한궤도)')", + excel_ref_sheet: "중기목록표", + code_ref: "X00270", + }, + { + id: "EQ-015", + item_no: "15", + category: "equipment", + name: "덤프트럭", + spec: "2.5톤:할증125%", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('덤프트럭')", + excel_ref_sheet: "중기목록표", + code_ref: "X00272", + }, + { + id: "EQ-016", + item_no: "16", + category: "equipment", + name: "덤프트럭", + spec: "15톤:할증125%", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('덤프트럭')", + excel_ref_sheet: "중기목록표", + code_ref: "X00275", + }, + { + id: "EQ-017", + item_no: "17", + category: "equipment", + name: "굴삭기(타이어)", + spec: "0.6㎥", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('굴삭기(타이어)')", + excel_ref_sheet: "중기목록표", + code_ref: "X00283", + }, + { + id: "EQ-018", + item_no: "18", + category: "equipment", + name: "덤프트럭", + spec: "24톤", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('덤프트럭')", + excel_ref_sheet: "중기목록표", + code_ref: "X00292", + }, + { + id: "EQ-019", + item_no: "19", + category: "equipment", + name: "콘크리트 믹서", + spec: "0.45㎥", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('콘크리트 믹서')", + excel_ref_sheet: "중기목록표", + code_ref: "X00350", + }, + { + id: "EQ-020", + item_no: "20", + category: "equipment", + name: "커터(콘크리트 및 아스팔트용)", + spec: "320-440mm", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('커터(콘크리트 및 아스팔트용)')", + excel_ref_sheet: "중기목록표", + code_ref: "X00352", + }, + { + id: "EQ-021", + item_no: "21", + category: "equipment", + name: "트럭탑재형 크레인", + spec: "10톤", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('트럭탑재형 크레인')", + excel_ref_sheet: "중기목록표", + code_ref: "X00568", + }, + { + id: "EQ-022", + item_no: "22", + category: "equipment", + name: "덤프트럭", + spec: "24톤(할증125%)", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('덤프트럭')", + excel_ref_sheet: "중기목록표", + code_ref: "X00740", + }, + { + id: "EQ-023", + item_no: "23", + category: "equipment", + name: "굴삭기+부착용집게(06)", + spec: "", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('굴삭기+부착용집게(06)')", + excel_ref_sheet: "중기목록표", + code_ref: "X00750", + }, + { + id: "EQ-024", + item_no: "24", + category: "equipment", + name: "동력분무기", + spec: "4.85㎾", + unit: "시간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Equipment.time_calc('동력분무기')", + excel_ref_sheet: "중기목록표", + code_ref: "X00751", + }, + { + id: "MT-001", + item_no: "1", + category: "material", + name: "비닐", + spec: "0.07mm", + unit: "M2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('비닐')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00011", + }, + { + id: "MT-002", + item_no: "2", + category: "material", + name: "면목", + spec: "A28", + unit: "M", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('면목')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00026", + }, + { + id: "MT-003", + item_no: "3", + category: "material", + name: "문양거푸집", + spec: "910*910*25", + unit: "매", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('문양거푸집')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00028", + }, + { + id: "MT-004", + item_no: "4", + category: "material", + name: "블레이드", + spec: "d=320~400mm t=3.2", + unit: "개", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('블레이드')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00029", + }, + { + id: "MT-005", + item_no: "5", + category: "material", + name: "결속선", + spec: "#20 , 0.9M/M", + unit: "KG", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('결속선')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00034", + }, + { + id: "MT-006", + item_no: "6", + category: "material", + name: "요소", + spec: "", + unit: "KG", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('요소')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00056", + }, + { + id: "MT-007", + item_no: "7", + category: "material", + name: "인산질비료", + spec: "용성인비인산", + unit: "KG", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('인산질비료')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00057", + }, + { + id: "MT-008", + item_no: "8", + category: "material", + name: "잡품", + spec: "주연료의 %", + unit: "%", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('잡품')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00109", + }, + { + id: "MT-009", + item_no: "9", + category: "material", + name: "시멘트", + spec: "별도계상", + unit: "KG", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('시멘트')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00117", + }, + { + id: "MT-010", + item_no: "10", + category: "material", + name: "레미콘", + spec: "별도계상", + unit: "M3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('레미콘')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00125", + }, + { + id: "MT-011", + item_no: "11", + category: "material", + name: "패널", + spec: "600*1200mm", + unit: "매", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('패널')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00131", + }, + { + id: "MT-012", + item_no: "12", + category: "material", + name: "내부코너패널", + spec: "(200+200)*1200mm", + unit: "매", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('내부코너패널')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00132", + }, + { + id: "MT-013", + item_no: "13", + category: "material", + name: "천연부엽토", + spec: "", + unit: "KG", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('천연부엽토')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00157", + }, + { + id: "MT-014", + item_no: "14", + category: "material", + name: "파형강관(800M/M)", + spec: "별도계상", + unit: "M", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('파형강관(800M/M)')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00214", + }, + { + id: "MT-015", + item_no: "15", + category: "material", + name: "이형철근 D13", + spec: "별도계상", + unit: "TON", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('이형철근 D13')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00227", + }, + { + id: "MT-016", + item_no: "16", + category: "material", + name: "와이어매쉬", + spec: "#6,100*100", + unit: "M2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('와이어매쉬')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00231", + }, + { + id: "MT-017", + item_no: "17", + category: "material", + name: "휘발유", + spec: "무연", + unit: "L", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('휘발유')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00302", + }, + { + id: "MT-018", + item_no: "18", + category: "material", + name: "경유", + spec: "저유황", + unit: "L", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('경유')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00303", + }, + { + id: "MT-019", + item_no: "19", + category: "material", + name: "레미콘(울진)", + spec: "25-18-80(세액포함)", + unit: "M3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('레미콘(울진)')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00344", + }, + { + id: "MT-020", + item_no: "20", + category: "material", + name: "레미콘(울진)", + spec: "25-21-80(세액포함)", + unit: "M3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('레미콘(울진)')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00424", + }, + { + id: "MT-021", + item_no: "21", + category: "material", + name: "깬돌", + spec: "구입-자재별산", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('깬돌')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00453", + }, + { + id: "MT-022", + item_no: "22", + category: "material", + name: "모래", + spec: "별도계상", + unit: "㎥", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('모래')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00488", + }, + { + id: "MT-023", + item_no: "23", + category: "material", + name: "조달수수료", + spec: "", + unit: "%", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('조달수수료')", + excel_ref_sheet: "재료비목록표", + code_ref: "M00918", + }, + { + id: "MT-024", + item_no: "24", + category: "material", + name: "물", + spec: "", + unit: "ℓ", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('물')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01054", + }, + { + id: "MT-025", + item_no: "25", + category: "material", + name: "떼", + spec: "", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('떼')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01150", + }, + { + id: "MT-026", + item_no: "26", + category: "material", + name: "종자", + spec: "초본류", + unit: "KG", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('종자')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01225", + }, + { + id: "MT-027", + item_no: "27", + category: "material", + name: "종자", + spec: "목본류", + unit: "KG", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('종자')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01226", + }, + { + id: "MT-028", + item_no: "28", + category: "material", + name: "낙석방지책", + spec: "H2500×W2000(표준)", + unit: "경간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('낙석방지책')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01324", + }, + { + id: "MT-029", + item_no: "29", + category: "material", + name: "낙석방지책", + spec: "H2500×W2000(단부)", + unit: "경간", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('낙석방지책')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01325", + }, + { + id: "MT-030", + item_no: "30", + category: "material", + name: "P.V.C PIPE", + spec: "VG2(50m/m)", + unit: "M", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('P.V.C PIPE')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01436", + }, + { + id: "MT-031", + item_no: "31", + category: "material", + name: "파형강관", + spec: "Φ800m/m, 2.7t", + unit: "M", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('파형강관')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01513", + }, + { + id: "MT-032", + item_no: "32", + category: "material", + name: "모래", + spec: "상차가(안동)", + unit: "M3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('모래')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01514", + }, + { + id: "MT-033", + item_no: "33", + category: "material", + name: "자갈", + spec: "상차가(영주)", + unit: "M3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('자갈')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01515", + }, + { + id: "MT-034", + item_no: "34", + category: "material", + name: "막자갈", + spec: "상차가(영주)", + unit: "M3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('막자갈')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01516", + }, + { + id: "MT-035", + item_no: "35", + category: "material", + name: "시멘트", + spec: "40Kg/포", + unit: "대", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('시멘트')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01517", + }, + { + id: "MT-036", + item_no: "36", + category: "material", + name: "이형철근,SD400", + spec: "D13~32m/m(세액별도)", + unit: "TON", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('이형철근,SD400')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01518", + }, + { + id: "MT-037", + item_no: "37", + category: "material", + name: "파쇄석(L3=45~55cm)", + spec: "상차가(영주)", + unit: "톤", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Material.sum('파쇄석(L3=45~55cm)')", + excel_ref_sheet: "재료비목록표", + code_ref: "M01519", + }, + { + id: "LB-001", + item_no: "1", + category: "labor", + name: "형틀목공", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('형틀목공')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00002", + }, + { + id: "LB-002", + item_no: "2", + category: "labor", + name: "철근공", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('철근공')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00004", + }, + { + id: "LB-003", + item_no: "3", + category: "labor", + name: "석공", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('석공')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00005", + }, + { + id: "LB-004", + item_no: "4", + category: "labor", + name: "콘크리트공", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('콘크리트공')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00007", + }, + { + id: "LB-005", + item_no: "5", + category: "labor", + name: "조경공", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('조경공')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00012", + }, + { + id: "LB-006", + item_no: "6", + category: "labor", + name: "작업반장", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('작업반장')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00014", + }, + { + id: "LB-007", + item_no: "7", + category: "labor", + name: "특별인부", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('특별인부')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00015", + }, + { + id: "LB-008", + item_no: "8", + category: "labor", + name: "보통인부", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('보통인부')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00016", + }, + { + id: "LB-009", + item_no: "9", + category: "labor", + name: "용접공", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('용접공')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00019", + }, + { + id: "LB-010", + item_no: "10", + category: "labor", + name: "포장공", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('포장공')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00025", + }, + { + id: "LB-011", + item_no: "11", + category: "labor", + name: "건설기계운전사", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('건설기계운전사')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00038", + }, + { + id: "LB-012", + item_no: "12", + category: "labor", + name: "화물차운전사", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('화물차운전사')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00039", + }, + { + id: "LB-013", + item_no: "13", + category: "labor", + name: "일반기계운전사", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('일반기계운전사')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00040", + }, + { + id: "LB-014", + item_no: "14", + category: "labor", + name: "배관공(수도)", + spec: "", + unit: "인", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Labor.total_mandays('배관공(수도)')", + excel_ref_sheet: "노무비목록표", + code_ref: "L00061", + }, + { + id: "WS-001", + item_no: "1", + category: "waste", + name: "폐기물처리", + spec: "폐콘크리트", + unit: "TON", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Waste.estimate('폐기물처리')", + excel_ref_sheet: "일식견적목록표", + code_ref: "W00045", + }, + { + id: "WS-002", + item_no: "2", + category: "waste", + name: "폐기물운반", + spec: "30km이하", + unit: "TON", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Waste.estimate('폐기물운반')", + excel_ref_sheet: "일식견적목록표", + code_ref: "W00046", + }, + { + id: "WS-003", + item_no: "3", + category: "waste", + name: "깬돌", + spec: "구입별산", + unit: "m2", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Waste.estimate('깬돌')", + excel_ref_sheet: "일식견적목록표", + code_ref: "W01835", + }, + { + id: "WS-004", + item_no: "4", + category: "waste", + name: "깬돌", + spec: "구입별산", + unit: "Ton", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Waste.estimate('깬돌')", + excel_ref_sheet: "일식견적목록표", + code_ref: "W02335", + }, + { + id: "WS-005", + item_no: "5", + category: "waste", + name: "무대처리", + spec: "", + unit: "m3", + raw_qty: null, + allowance_rate: 1.0, + calc_qty: null, + formula_desc: "= Waste.estimate('무대처리')", + excel_ref_sheet: "일식견적목록표", + code_ref: "W02336", + }, +]; diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts index 869947ba..d7b74aa6 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts @@ -1,26 +1,222 @@ /* ============================================================================= * B08_wf5_Quantity_UI_Page.ts - * 로그인 후 08: 5차 워크플로우 (수량 산출) + * 로그인 후 08: 5차 워크플로우 (수량 산출 UI 메인) * - * ⚠️ 본문 준비 중 — 워크플로우 셸(헤더+스텝바)만 구성. 추후 구체화. - * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용. + * 엑셀 내역서(2025년 산불진화임도...) 연동을 위한 전체 145개 템플릿 UI. + * 입력데이터(B07 또는 이전 산출값)는 비어있는(null) 상태로 조율되며, 수식 기반 템플릿 항목을 표시합니다. + * 오직 B08_wf5_Quantity 폴더 내의 파일만 다룹니다. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold"; +import { + INITIAL_QUANTITY_TEMPLATES, + QUANTITY_CATEGORY_LABELS, + QuantityCategory, + QuantityItemTemplate, +} from "./B08_wf5_Quantity_Template"; +import "./B08_wf5_Quantity_UI_Style.css"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/* ----------------------------------------------------------------------------- - * 페이지 진입점 - * -------------------------------------------------------------------------- */ +/** 현재 활성화된 공종 탭 */ +let currentCategory: QuantityCategory = "earthwork"; + +/** + * B08 수량 산출 메인 렌더링 함수 + */ export async function renderB08Quantity(root: HTMLElement): Promise { + // 1. 공통 Workflow Shell 렌더링 await renderPendingWorkflow(root, { title: L("B08_Quantity_Title"), steps: workflowSteps(), activeStep: 5, }); + + // 2. 본문 컨테이너 슬롯 확보 (Workflow Shell 내부 본문 영역) + const bodyContainer = root.querySelector(".workflow-body") || root; + bodyContainer.innerHTML = ""; // 기존 준비중 메시지 대체 + + const container = document.createElement("div"); + container.className = "b08-quantity-container"; + bodyContainer.appendChild(container); + + // 3. UI 컴포넌트 조합 + renderSummaryGrid(container); + renderTabBar(container); + renderTableArea(container); + renderActionBar(container); +} + +/** + * 1. 상단 요약 카드 그리드 렌더링 (입력 데이터 펜딩 상태) + */ +function renderSummaryGrid(parent: HTMLElement): void { + const grid = document.createElement("div"); + grid.className = "b08-summary-grid"; + + const summaryItems = [ + { title: "전체 엑셀 템플릿", val: "총 145개 항목", desc: "6개 세부 공종 100% 매핑 완료" }, + { title: "단가산출 / 일위대가", val: "65개 표준공종", desc: "토공 40종 + 일위대가 25종" }, + { title: "자재 & 노무비", val: "51개 자재/인력", desc: "자재 37종 + 노무인부 14종" }, + { title: "중기 & 일식/폐기물", val: "29개 중기/일식", desc: "중기시간 24종 + 폐기물 5종" }, + ]; + + grid.innerHTML = summaryItems + .map( + (item) => ` +
+
${item.title}
+
${item.val}
+
${item.desc}
+
+ `, + ) + .join(""); + + parent.appendChild(grid); +} + +/** + * 2. 공종 탭 바 렌더링 + */ +function renderTabBar(parent: HTMLElement): void { + const tabBar = document.createElement("div"); + tabBar.className = "b08-tab-bar"; + + const categories: QuantityCategory[] = [ + "earthwork", + "structure", + "material", + "equipment", + "waste", + "labor", + ]; + + categories.forEach((cat) => { + const btn = document.createElement("button"); + btn.className = `b08-tab-btn ${cat === currentCategory ? "active" : ""}`; + btn.textContent = QUANTITY_CATEGORY_LABELS[cat].ko; + btn.addEventListener("click", () => { + currentCategory = cat; + // 탭 업데이트 + tabBar.querySelectorAll(".b08-tab-btn").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + // 테이블 재렌더링 + const tableArea = parent.querySelector("#b08-table-area"); + if (tableArea) { + tableArea.innerHTML = ""; + renderTableContent(tableArea as HTMLElement); + } + }); + tabBar.appendChild(btn); + }); + + parent.appendChild(tabBar); +} + +/** + * 3. 수량 집계 데이터 테이블 영역 렌더링 + */ +function renderTableArea(parent: HTMLElement): void { + const tableArea = document.createElement("div"); + tableArea.id = "b08-table-area"; + tableArea.className = "b08-table-wrapper"; + parent.appendChild(tableArea); + + renderTableContent(tableArea); +} + +/** + * 테이블 내용 렌더링 (현재 카테고리 기준) + */ +function renderTableContent(parent: HTMLElement): void { + const items = INITIAL_QUANTITY_TEMPLATES.filter((item) => item.category === currentCategory); + + const table = document.createElement("table"); + table.className = "b08-table"; + + table.innerHTML = ` + + + 호표 + 명칭 + 규격 + 단위 + 설계 산출 수량 (raw_qty) + 할증률 + 최종 산출 수량 + 엑셀 수식 및 산출 근거 (Formula) + 연동 시트 / 코드 + + + + ${items.map((item) => renderTableRow(item)).join("")} + + `; + + parent.appendChild(table); +} + +/** + * 테이블 행 HTML 생성 + */ +function renderTableRow(item: QuantityItemTemplate): string { + const rawQtyDisplay = + item.raw_qty !== null + ? item.raw_qty.toLocaleString() + : `[입력 대기]`; + + const calcQtyDisplay = + item.calc_qty !== null + ? item.calc_qty.toLocaleString() + : `[수식 자동계산]`; + + const codeBadge = item.code_ref + ? `${item.code_ref}` + : ""; + + return ` + + ${item.item_no} + ${item.name} + ${item.spec} + ${item.unit} + ${rawQtyDisplay} + ${item.allowance_rate} + ${calcQtyDisplay} + ${item.formula_desc} + + ${item.excel_ref_sheet} + ${codeBadge} + + + `; +} + +/** + * 4. 하단 컨트롤/액션 바 렌더링 + */ +function renderActionBar(parent: HTMLElement): void { + const actionBar = document.createElement("div"); + actionBar.className = "b08-action-bar"; + + actionBar.innerHTML = ` +
+ 💡 엑셀 내역서 145개 전체 템플릿 매핑 완료: 입력 수량값은 차후 이전 워크플로우(B07 등) 및 수식 엔진에 의해 자동 기입됩니다. +
+ + `; + + const confirmBtn = actionBar.querySelector(".b08-btn-confirm"); + confirmBtn?.addEventListener("click", () => { + alert("B08 수량 템플릿 항목(145종)이 확정되었습니다. B09 견적 파이프라인으로 전달됩니다."); + }); + + parent.appendChild(actionBar); } diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css new file mode 100644 index 00000000..68935516 --- /dev/null +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Style.css @@ -0,0 +1,212 @@ +/* ============================================================================= + * B08_wf5_Quantity_UI_Style.css + * B08 수량 산출 UI 스타일 정의 + * ========================================================================== */ + +.b08-quantity-container { + display: flex; + flex-direction: column; + gap: 20px; + padding: 24px; + background-color: var(--bg-primary, #1e1e2d); + color: var(--text-primary, #ffffff); + font-family: + "Inter", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; + min-height: calc(100vh - 120px); + box-sizing: border-box; +} + +/* 요약 카드 그리드 */ +.b08-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.b08-card { + background: var(--bg-secondary, #2b2b40); + border: 1px solid var(--border-color, #3a3a55); + border-radius: 12px; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transition: + transform 0.2s ease, + border-color 0.2s ease; +} + +.b08-card:hover { + transform: translateY(-2px); + border-color: var(--accent-color, #4e80ee); +} + +.b08-card-header { + font-size: 0.85rem; + color: var(--text-secondary, #9a9ab0); + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} + +.b08-card-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-highlight, #38ef7d); +} + +.b08-card-value.empty-state { + color: #ff9f43; + font-size: 1.1rem; + font-style: italic; +} + +.b08-card-sub { + font-size: 0.75rem; + color: #727290; +} + +/* 공종 탭 버튼 영역 */ +.b08-tab-bar { + display: flex; + gap: 8px; + border-bottom: 2px solid var(--border-color, #3a3a55); + padding-bottom: 8px; + overflow-x: auto; +} + +.b08-tab-btn { + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary, #a0a0c0); + padding: 10px 18px; + border-radius: 8px 8px 0 0; + cursor: pointer; + font-weight: 600; + font-size: 0.9rem; + transition: all 0.2s ease; + white-space: nowrap; +} + +.b08-tab-btn:hover { + background: var(--bg-hover, rgba(255, 255, 255, 0.05)); + color: #ffffff; +} + +.b08-tab-btn.active { + background: var(--bg-secondary, #2b2b40); + border-color: var(--accent-color, #4e80ee) var(--accent-color, #4e80ee) transparent + var(--accent-color, #4e80ee); + color: var(--accent-color, #4e80ee); +} + +/* 수량 집계 데이터 테이블 */ +.b08-table-wrapper { + background: var(--bg-secondary, #2b2b40); + border-radius: 12px; + border: 1px solid var(--border-color, #3a3a55); + overflow-x: auto; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.b08-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 0.88rem; +} + +.b08-table th { + background-color: rgba(0, 0, 0, 0.25); + color: var(--text-secondary, #b5b5d0); + font-weight: 600; + padding: 12px 14px; + border-bottom: 1px solid var(--border-color, #3a3a55); + white-space: nowrap; +} + +.b08-table td { + padding: 12px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + color: #e0e0f0; +} + +.b08-table tbody tr:hover { + background-color: rgba(255, 255, 255, 0.03); +} + +.b08-badge { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; +} + +.b08-badge-sheet { + background-color: rgba(78, 128, 238, 0.15); + color: #4e80ee; + border: 1px solid rgba(78, 128, 238, 0.3); +} + +.b08-badge-code { + background-color: rgba(155, 89, 182, 0.15); + color: #af7ac5; + border: 1px solid rgba(155, 89, 182, 0.3); +} + +.b08-empty-val { + color: #ff9f43; + font-style: italic; + font-weight: 500; +} + +.b08-formula-cell { + font-family: "Fira Code", "Courier New", monospace; + font-size: 0.8rem; + color: #00cec9; + background: rgba(0, 206, 201, 0.05); + padding: 4px 8px; + border-radius: 4px; +} + +/* 하단 컨트롤 바 */ +.b08-action-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary, #2b2b40); + padding: 16px 24px; + border-radius: 12px; + border: 1px solid var(--border-color, #3a3a55); +} + +.b08-info-text { + font-size: 0.85rem; + color: #9a9ab0; +} + +.b08-btn-confirm { + background: linear-gradient(135deg, #4e80ee 0%, #38ef7d 100%); + color: #000000; + font-weight: 700; + border: none; + padding: 12px 24px; + border-radius: 8px; + cursor: pointer; + transition: + opacity 0.2s ease, + transform 0.2s ease; +} + +.b08-btn-confirm:hover { + opacity: 0.9; + transform: scale(1.02); +} diff --git a/config/config_system.py b/config/config_system.py index 9dae5b5d..8b44a5f2 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -244,6 +244,57 @@ SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5} +# ───────────────────────────────────────────────────────────────────────── +# 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2) +# +# 출처: 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 임도의 설계 및 시설기준. +# 기준은 임도등급이 아니라 `설계속도 × 지형구분(일반/특수)`으로 규정되어 있어 +# 등급은 grade_to_design_speed로 간접 매핑한다. +# 위 5-3의 경로탐색(평면) 기준(FOREST_ROAD_MAX_GRADE 등)과는 별개의 값이므로 +# 서로 혼용하지 않는다. +# ───────────────────────────────────────────────────────────────────────── +FOREST_ROAD_PROFILE_CRITERIA = { + # 설계속도(km/h)별 법정 기준 + "design_speed": { + 40: { + "max_grade_pct": {"normal": 7.0, "special": 10.0}, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 450.0, + "min_curve_length_m": 40.0, + }, + 30: { + "max_grade_pct": {"normal": 8.0, "special": 12.0}, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 250.0, + "min_curve_length_m": 30.0, + }, + 20: { + "max_grade_pct": {"normal": 9.0, "special": 14.0}, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 100.0, + "min_curve_length_m": 20.0, + }, + }, + # 임도등급 → 기본 설계속도. 작업임도는 별표에 종단 기준이 없어 20km/h를 준용한다. + "grade_to_design_speed": {"trunk": 40, "branch": 30, "work": 20}, + # 특수지형에서 기준 적용이 어려운 경우 노면포장 시에 한하여 허용되는 상한 + "paved_exception_grade_pct": 18.0, + # 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다 + "vertical_curve_skip_delta_pct": 5.0, + # 곡선 중첩 방지용 최소 직선(tangent) 길이 (규정 외 실무 기본값) + "min_tangent_length_m": 20.0, + # 절·성토 균형 구역 기본 길이 (None이면 노선 전체를 1구역으로 본다) + "balance_segment_length_m": None, + # 주 진행방향 자동 판정 기준: |시종점 고도차| / (최고−최저) 가 이 값 이상이면 + # 한 방향으로 오르내리는 노선으로 보고 반대 방향에 역기울기 상한을 적용한다. + # V자(계곡 횡단)·Λ자(능선 통과) 노선은 이 비율이 낮아 역기울기 판정을 하지 않는다. + "main_direction_monotone_ratio": 0.5, +} +GRADE_TERRAIN_TYPES = ("normal", "special") +# 주 진행방향: auto(자동 판정) / ascending(상행) / descending(하행) / none(역기울기 미적용) +GRADE_MAIN_DIRECTIONS = ("auto", "ascending", "descending", "none") + + # ───────────────────────────────────────────────────────────────────────── # 6. 저장소 경로 # ───────────────────────────────────────────────────────────────────────── diff --git a/resources/templete_calc_cost.xlsx b/resources/templete_calc_cost.xlsx new file mode 100644 index 00000000..2793157a Binary files /dev/null and b/resources/templete_calc_cost.xlsx differ