feat(B05/B06): 종횡단 상세 프론트 공유 캐시 + 계획선 호 통과 기하

페이지 간 캐시 공유
- B06_wf3_ProfileCross_Section_Store.ts 신설: 종횡단 상세를 projectId:routeId
  키로 들고 있는 모듈 싱글턴 캐시. B05와 B06이 같은 객체 참조를 보므로 B06이
  측점 설계를 제자리 갱신하면 B05가 다음 그리기에서 그대로 본다 — 횡단을 고친
  뒤 B05 유토곡선이 옛 값으로 그려지던 문제의 원인이 페이지별 개별 fetch였다.
- 두 페이지 진입을 loadSectionDetail()로 통일(동시 호출은 Promise 공유).
- 정본이 다시 쓰이는 조작에 캐시 갱신: 횡단 재생성 → replaceSectionDetail,
  계획선 편집 저장 → invalidateSectionDetail.

계획선 호 기하
- 배관 지점(지면선 × 배관 세로선 교점)이 호 위에 오도록 변화점 표고를 반복
  보정. 대칭 종단곡선은 꼭짓점을 지나지 않아 중앙종거만큼 어긋나 있었다.
  시작점·종점과 각 호, 호와 호 사이는 직선이 접선으로 잇는다.
- 호 반경 기본값을 설계 기준의 종단곡선 최소 반경으로 지정하고 curve_radii
  편집 델타에 실어 B05 테이블에서 사용자가 그대로 고칠 수 있게 했다.

유토곡선 Y축
- B05에만 있던 종단용 sticky 표고축 탓에 가로로 훑으면 유토곡선 눈금은 흘러가고
  표고축만 남아 Y축이 높이로 읽혔다. createMassHaulChart에 onAxis 콜백을 더해
  유토곡선용 sticky 누가토량 축을 따로 고정.

검증: solve 재실행 후 배관 4개 자리에서 계획고=지반고(오차 ≤0.0001m), 호 비겹침
확인. typecheck·vite build·ruff·B03 테스트 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 20:28:23 +09:00
co-authored by Claude Opus 5
parent a9741277cf
commit 1ee6516dd5
7 changed files with 155 additions and 13 deletions
@@ -31,6 +31,9 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Alignment import (
ALIGNMENT_SCHEMA_VERSION,
AlignmentPolicy,
build_alignment,
build_curves,
chainage_key,
evaluate,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Solver import (
grade_limits,
@@ -125,10 +128,15 @@ def design_pipe_anchored_profile(
) -> tuple[dict[str, Any], dict[str, Any]]:
"""배관 배치 측점을 변화점으로 삼는 1차 계획선.
변화점 표고는 그 자리의 지반고다 — 계획선이 지면선과 교차하는 지점에 배관이 앉는다.
시·종점은 지반고에 사용자 오프셋을 더한다(기존 계약 유지). 종단곡선 R은
`build_alignment`이 기본값(`default_curve_radius_m`)으로 얹고, 기울기 위반은
막지 않고 경고로 남긴다 — 배관 위치가 우선이고 조정은 사용자 몫이다.
기하 규칙(2026-08-03 사용자 확정): **배관 자리(지면선과 배관 세로선의 교점)가 호 위에
있어야 한다.** 시작점·종점과 각 호, 호와 호 사이는 직선이 접선(tangent)으로 잇는다.
대칭 종단곡선은 원래 변화점(꼭짓점)을 지나지 않으므로, 변화점 표고를 반복 보정해
**곡선이 정확히 배관 지반고를 통과**하도록 맞춘다(중앙종거만큼 꼭짓점을 밀어낸다).
호 반경은 설계 기준의 종단곡선 최소 반경(`options.min_vertical_radius_m`,
config_system 법정 기준 해석값)을 기본으로 각 변화점에 지정하며, 이 값은 편집
델타(curve_radii)로 저장돼 사용자가 B05 테이블에서 그대로 고칠 수 있다.
기울기 위반은 막지 않고 경고로 남긴다 — 배관 위치가 우선이고 조정은 사용자 몫이다.
"""
options.validate()
chainage, ground = ground_profile(longitudinal)
@@ -172,10 +180,30 @@ def design_pipe_anchored_profile(
raise ValueError("계획선 변화점으로 쓸 배관 배치 측점이 없습니다.")
base_s = np.array([0.0, *anchors, total], dtype=np.float64)
# 배관 자리 표고 = 지반고(지면선 교차). 시·종점만 오프셋을 얹는다.
base_z = np.interp(base_s, chainage, ground)
base_z[0] = fixed[0]
base_z[-1] = fixed[1]
# 목표: 배관 자리 계획고 = 지반고(지면선 교차점이 호 위). 시·종점만 오프셋을 얹는다.
target = np.interp(base_s, chainage, ground)
target[0] = fixed[0]
target[-1] = fixed[1]
# 각 배관 변화점의 호 반경 기본값 — 설계 기준의 종단곡선 최소 반경.
anchor_radii = {chainage_key(a): float(options.min_vertical_radius_m) for a in anchors}
edits = dict(edits or {})
edits["curve_radii"] = {**anchor_radii, **(edits.get("curve_radii") or {})}
# 대칭 종단곡선은 꼭짓점(변화점)을 지나지 않는다 — 곡선이 배관 지반고를 통과하도록
# 변화점 표고를 반복 보정한다(호가 안쪽으로 파고드는 중앙종거만큼 꼭짓점을 밀어낸다).
# 오차는 반복마다 중앙종거의 고차항만 남아 수 회면 mm 아래로 떨어진다.
base_z = target.copy()
radii_for_iter = edits["curve_radii"]
for _ in range(12):
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii_for_iter)
plan_at = evaluate(base_s, base_z, curves, base_s)
error = target - plan_at
error[0] = 0.0
error[-1] = 0.0
if float(np.max(np.abs(error))) < 1e-4:
break
base_z = base_z + error
alignment = build_alignment(
base_s=base_s,
+8 -2
View File
@@ -44,10 +44,13 @@ import {
} from "./B05_wf2_Route_UI_IrregularStations";
import {
fetchSectionContext,
fetchSectionDetail,
type SectionDetailResponse,
type SectionStation,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import {
invalidateSectionDetail,
loadSectionDetail,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Section_Store";
import "./B05_wf2_Route_UI_Style.css";
type GradeClass = RoutePanelValues["gradeClass"];
@@ -440,7 +443,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
let detail: SectionDetailResponse;
profilePanel.setLoading("종단면 자료를 불러오는 중…");
try {
detail = await fetchSectionDetail(activeProjectId, routeId);
// 공유 캐시 — B06이 이미 받아 뒀으면 같은 객체를 재사용해 두 페이지가 항상 같은 값을 본다.
detail = await loadSectionDetail(activeProjectId, routeId);
} catch {
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
currentSectionDetail = null;
@@ -582,6 +586,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
try {
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
await profilePanel.save();
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다.
invalidateSectionDetail(activeProjectId);
// 비정규 측점(구조물)이 있으면 확정 시 그 횡단까지 생성하도록 지표 샘플러 입력을 함께 보낸다.
await confirmRoute(activeProjectId, {
filter_key: latest?.surface_params.source_filter,
@@ -83,6 +83,8 @@ export interface RouteMassHaulDrawParams {
heightPx: number;
selectedStationId: string | null;
onSelectStation: (stationId: string) => void;
/** 가로 스크롤에도 왼쪽에 고정되는 Y축(누가토량)을 그리도록 눈금을 되돌려 준다. */
onAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void;
}
export interface RouteMassHaulPanel {
@@ -209,6 +211,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
params.widthPx,
params.onSelectStation,
haulPlan,
params.onAxis,
);
legendLayer.style.top = `${params.legendTopPx}px`;
legendLayer.append(
@@ -569,6 +569,10 @@ export function createRouteProfilePanel(
}
// 유토곡선 SVG는 종단 그래프와 **같은 부모의 형제**로 넣는다. 감싸는 상자를 하나라도
// 끼우면 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점선이 어긋난다.
// 유토곡선의 Y축(누가토량)도 종단 표고축과 같은 방식으로 왼쪽에 고정한다 —
// 안 그러면 가로로 훑을 때 유토곡선 눈금은 흘러가고 종단 표고축만 남아,
// 유토곡선 Y축이 "높이"로 읽힌다(2026-08-03 사용자 보고).
let massYAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
const massHaulChart =
alignment && designProfiles[0]
? massHaul.draw({
@@ -587,6 +591,9 @@ export function createRouteProfilePanel(
axisX: LONG_PAD.left,
},
legendTopPx: chartHeight + 6,
onAxis: (axis) => {
massYAxis = axis;
},
stationInterval: stationIntervalM ?? 1,
widthPx: width,
heightPx: tableHeight,
@@ -625,7 +632,13 @@ export function createRouteProfilePanel(
splitBar.addEventListener("pointermove", onMove);
splitBar.addEventListener("pointerup", onUp);
});
canvas.append(splitBar, massHaulChart);
// 종단 그래프와 같은 문법으로 감싼다 — sticky Y축 앵커의 기준(position: relative)이 필요하다.
const massWrap = document.createElement("div");
massWrap.className = "b05-profile__chart";
massWrap.style.height = `${tableHeight}px`;
massWrap.append(massHaulChart);
if (massYAxis) massWrap.append(buildStickyYAxis(massYAxis, tableHeight));
canvas.append(splitBar, massWrap);
}
body.replaceChildren(canvas);
body.scrollLeft = scrollLeft;