feat(횡단): 소단을 미리보기·재계산에 실어 화면에 계단이 서게 함 (계획서 3-9)
계단이 값에만 있던 것을 화면까지 연결함. 배선 — 세션 열쇠 `berm`(측점키 → 폭·간격·기울기)을 등록표에 두고, `readBermSession` 으로 읽어 ① 브라우저 재계산(`refreshCrossDesigns`)과 ② 서버 미리보기(`cross-design/preview` 의 `berms`) 양쪽에 실음. 암 경계선 오프셋과 같은 길이라 「계획선을 고치면 계단이 사라지는」 일이 없음. 실화면 확인(8001·5174, `/api/health` `stale:false`) — 측점 4120.0m 에 소단을 놓고 계획고를 한 칸 올렸다 내려 전 구간 재계산을 태움. · 폭 0.5m · 간격 3.0m → 절토 6.84 → 8.10㎡ (토사 2.42→3.24 · 암 4.42→4.86) · 폭 1.0m · 간격 2.0m → 절토 6.84 → 12.96㎡, 횡단도에 **계단이 눈으로 보임** · 소단을 안 놓은 옆 측점(4100.0m)은 3.77㎡ 그대로 — 놓은 곳만 달라짐 · 되돌린 뒤 6.84㎡ 로 복귀. [저장]·[확정] 안 눌렀으므로 정본은 그대로. `cut_slope_segments` 신설 — 절토 사면을 경사 구간별로 쪼갠 목록(파이썬·TS 짝). 법정 경사 검사가 읽을 값임(다른 창 요청). 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보이므로(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71), 검사는 소단을 뺀 구간 자체를 봐야 함. 실측 — 소단을 놓아도 구간별 경사비는 1.0 그대로 나옴. 평탄부(소단)와 지반 만난 뒤 구간은 싣지 않음. 자체검증 — 거울 시험에 사면 구간 대조를 더해 파이썬·TS 일치 확인. 전체 539 passed · 18 skipped. TS 타입 검사 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -253,6 +253,8 @@ export async function previewCrossDesigns(
|
||||
fullDesigns?: boolean;
|
||||
/** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */
|
||||
rockBoundaryOffsets?: Record<string, number>;
|
||||
/** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */
|
||||
berms?: Record<string, { width_m: number; interval_m: number; slope_deg: number }>;
|
||||
},
|
||||
): Promise<CrossDesignPreviewResponse> {
|
||||
return requestJson<CrossDesignPreviewResponse>(
|
||||
@@ -264,6 +266,7 @@ export async function previewCrossDesigns(
|
||||
standard_cross_section: standardCrossSection ?? null,
|
||||
full_designs: options?.fullDesigns ?? false,
|
||||
rock_boundary_offsets: options?.rockBoundaryOffsets ?? null,
|
||||
berms: options?.berms ?? null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -28,7 +28,8 @@ import { computeCrossDesign } from "@util/common_util_cross_design";
|
||||
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
||||
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||
import { readBermSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSpec } from "@util/common_util_cross_berm";
|
||||
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
||||
import {
|
||||
effectiveStandardCross,
|
||||
@@ -162,6 +163,15 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
|
||||
const rockOffsets = readRockOffsets(projectId, input.routeId);
|
||||
const rockDefault = readRockBoundaryDefault(projectId);
|
||||
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
||||
// 순간 계단이 사라진다(계획서 3-9).
|
||||
const berms = readBermSession(projectId, input.routeId);
|
||||
const bermAt = (chainageM: number): BermSpec | null => {
|
||||
const spec = berms[rockKey(chainageM).toFixed(2)];
|
||||
return spec
|
||||
? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg }
|
||||
: null;
|
||||
};
|
||||
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
|
||||
// (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]).
|
||||
const choices = crossDesignChoices(projectId, input.routeId);
|
||||
@@ -204,6 +214,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
|
||||
paved: choice?.paved ?? Boolean(design.paved),
|
||||
standard,
|
||||
berm: bermAt(section.chainage_m),
|
||||
rockBoundaryOffsetM,
|
||||
twoStageSlope:
|
||||
choice?.two_stage_slope ??
|
||||
@@ -243,6 +254,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
|
||||
{
|
||||
fullDesigns: true,
|
||||
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
|
||||
berms: readBermSession(projectId, routeId),
|
||||
},
|
||||
);
|
||||
if (shouldApply && !shouldApply()) return [];
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -312,6 +313,64 @@ class _SectionGeometry:
|
||||
"""절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다."""
|
||||
return berm_elevation_at(self.cut_points(side), dist)
|
||||
|
||||
def cut_slope_segments(self) -> list[dict[str, Any]]:
|
||||
"""절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사가 읽는 값이다.
|
||||
|
||||
왜 필요한가 — 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져
|
||||
**위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). 검사는
|
||||
소단을 뺀 **사면 구간 자체의 경사**를 봐야 하므로 그 구간을 여기서 내보낸다.
|
||||
|
||||
· 평탄부(소단)는 싣지 않는다 — 검사 대상이 아니고 경사비가 무한대가 된다.
|
||||
· 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다.
|
||||
· `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None.
|
||||
암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다).
|
||||
"""
|
||||
segments: list[dict[str, Any]] = []
|
||||
for side in ("left", "right"):
|
||||
role = self.left_role if side == "left" else self.right_role
|
||||
if role != "cut":
|
||||
continue
|
||||
cross = self.cut_cross_dist(side)
|
||||
points = self.cut_points(side)
|
||||
sign = 1.0 if side == "left" else -1.0
|
||||
for index in range(1, len(points)):
|
||||
start_d, start_z = points[index - 1]
|
||||
end_d, end_z = points[index]
|
||||
if cross is not None and start_d >= cross - 1e-9:
|
||||
break # 지반과 만난 뒤는 절토가 없다
|
||||
if cross is not None and end_d > cross:
|
||||
# 지반과 만나는 점에서 구간을 자른다.
|
||||
end_z = berm_elevation_at(points, cross)
|
||||
end_d = cross
|
||||
run = end_d - start_d
|
||||
rise = end_z - start_z
|
||||
if run <= 1e-9 or rise <= 1e-6:
|
||||
continue # 길이 0·역방향은 검사 대상이 아니다
|
||||
if self.berm is not None and abs(run - self.berm.width_m) < 1e-6:
|
||||
# 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다.
|
||||
berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m
|
||||
if abs(rise - berm_rise) < 1e-9:
|
||||
continue
|
||||
material: str | None = None
|
||||
if self.two_stage:
|
||||
middle_d = (start_d + end_d) / 2.0
|
||||
middle_z = (start_z + end_z) / 2.0
|
||||
material = (
|
||||
"soil" if middle_z >= self._rock_boundary_z(side, middle_d) else "rock"
|
||||
)
|
||||
segments.append(
|
||||
{
|
||||
"side": side,
|
||||
"ratio": round(run / rise, 4),
|
||||
"rise_m": round(rise, 4),
|
||||
"run_m": round(run, 4),
|
||||
"start_offset_m": round(sign * start_d, 4),
|
||||
"end_offset_m": round(sign * end_d, 4),
|
||||
"material": material,
|
||||
}
|
||||
)
|
||||
return segments
|
||||
|
||||
def cut_cross_dist(self, side: str) -> float | None:
|
||||
"""절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4).
|
||||
|
||||
@@ -715,6 +774,8 @@ def compute_cross_design(
|
||||
),
|
||||
"ditch_area_m2": round(ditch_area, 4),
|
||||
"design_line": design_line,
|
||||
# 절토 사면을 경사 구간별로 쪼갠 목록 — 법정 경사 검사가 읽는다(소단 제외).
|
||||
"cut_slope_segments": geometry.cut_slope_segments(),
|
||||
}
|
||||
if drop > 0:
|
||||
# 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다.
|
||||
|
||||
@@ -553,6 +553,7 @@ async def preview_cross_designs(
|
||||
request.standard_cross_section,
|
||||
request.rock_boundary_offsets,
|
||||
project_root,
|
||||
request.berms,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(rebuild)
|
||||
|
||||
@@ -10,6 +10,12 @@ from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from common_util.common_util_cross_berm import (
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
BERM_DEFAULT_WIDTH_M,
|
||||
BermSpec,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
|
||||
@@ -343,6 +349,7 @@ def recompute_designs_for_alignment(
|
||||
standard: dict[str, Any] | None,
|
||||
rock_boundary_offsets: dict[str, float] | None = None,
|
||||
project_root: Path | None = None,
|
||||
berms: dict[str, dict[str, float]] | None = None,
|
||||
) -> None:
|
||||
modes = default_section_modes(longitudinal)
|
||||
pavement = pavement_suggestions(longitudinal)
|
||||
@@ -358,6 +365,17 @@ def recompute_designs_for_alignment(
|
||||
session_offsets[round(float(raw_key), 3)] = float(offset)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
# 측점별 소단 제원 — 값이 없는 측점은 소단 없음(종전 설계 그대로).
|
||||
session_berms: dict[float, BermSpec] = {}
|
||||
for raw_key, spec in (berms or {}).items():
|
||||
try:
|
||||
session_berms[round(float(raw_key), 3)] = BermSpec(
|
||||
width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)),
|
||||
interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)),
|
||||
slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)),
|
||||
)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
continue
|
||||
for section in cross_sections:
|
||||
chainage = float(section.get("chainage_m", 0.0))
|
||||
key = round(chainage, 3)
|
||||
@@ -380,6 +398,7 @@ def recompute_designs_for_alignment(
|
||||
two_stage_slope=bool(stored.get("two_stage_slope", True)),
|
||||
ditch_enabled=stored.get("ditch_enabled"),
|
||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||
berm=session_berms.get(key),
|
||||
**curve_widening_args(section),
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
|
||||
@@ -64,9 +64,7 @@ async def compute_haul_plan(
|
||||
{"haul_plan_for": result, "context": _mass_haul_context()},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id
|
||||
)
|
||||
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
|
||||
|
||||
@@ -298,6 +298,10 @@ class CrossDesignPreviewRequest(BaseModel):
|
||||
# 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는
|
||||
# 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다.
|
||||
rock_boundary_offsets: dict[str, float] | None = None
|
||||
# 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg}). 위와 같은 성격으로,
|
||||
# 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9).
|
||||
# 값이 없는 측점은 소단 없음 — 종전 설계 그대로다.
|
||||
berms: dict[str, dict[str, float]] | None = None
|
||||
|
||||
def edits(self) -> dict[str, Any]:
|
||||
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
|
||||
|
||||
@@ -52,6 +52,27 @@ export function readRockBoundarySession(
|
||||
return stored && typeof stored === "object" ? stored : {};
|
||||
}
|
||||
|
||||
/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */
|
||||
export interface BermSessionSpec {
|
||||
width_m: number;
|
||||
interval_m: number;
|
||||
slope_deg: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션에 쌓인 소단 제원(측점키 → 제원). 없거나 손상되면 빈 객체.
|
||||
*
|
||||
* 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어
|
||||
* 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9).
|
||||
*/
|
||||
export function readBermSession(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Record<string, BermSessionSpec> {
|
||||
const stored = readState<Record<string, BermSessionSpec>>("berm", projectId, routeId);
|
||||
return stored && typeof stored === "object" ? stored : {};
|
||||
}
|
||||
|
||||
/** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */
|
||||
export interface RockBoundaryStore {
|
||||
/** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */
|
||||
|
||||
Reference in New Issue
Block a user