Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -557,68 +557,11 @@
|
||||
text-shadow: 0 1px 2px var(--color-surface-raised);
|
||||
}
|
||||
|
||||
/* 방위 콤파스 — 3D 뷰어 우하단. 축척 막대(좌하단)와 짝. */
|
||||
/* 방위 콤파스를 놓을 자리 — 3D 뷰어 우하단. 축척 막대(좌하단)와 짝.
|
||||
위젯 모양·색은 공용(`ui_template/ui_template_compass.css`)이고 여기서는 위치만 준다. */
|
||||
.b04-surface__compass {
|
||||
position: absolute;
|
||||
right: var(--spacing-16);
|
||||
bottom: var(--spacing-16);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 1px 2px var(--color-surface-raised));
|
||||
}
|
||||
|
||||
/* inline SVG의 baseline 여백을 없앤다 — 안 그러면 아래로 5px 떠 모서리 여백이 어긋난다. */
|
||||
.b04-surface__compass svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 지면에 누운 나침반 링 — 좌표는 카메라 자세대로 매 프레임 다시 쓰인다.
|
||||
링 안쪽 면은 지형이 비치도록 아주 옅게만 깔아 도면 위 방위표처럼 읽힌다. */
|
||||
.b04-surface__compass-face {
|
||||
fill: var(--color-surface-raised);
|
||||
fill-opacity: 0.5;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.b04-surface__compass-ring {
|
||||
fill: none;
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.b04-surface__compass-tick {
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b04-surface__compass-tick--major {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
/* 도북 화살 — 이 위젯에서 가장 먼저 읽혀야 하는 하나. */
|
||||
.b04-surface__compass-arrow {
|
||||
fill: var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
/* 표고축 — 내려다볼수록 짧아져 시점 기울기를 한 번 더 알린다. */
|
||||
.b04-surface__compass-pole {
|
||||
stroke: var(--color-text-body);
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.b04-surface__compass-label {
|
||||
fill: var(--color-text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.b04-surface__compass-label--north {
|
||||
fill: var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
/* --- 하단 2D 지도 --- */
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
|
||||
import { createTerrainCompass } from "./B04_PreProcess_UI_Compass";
|
||||
import { createTerrainCompass } from "@ui/ui_template_compass";
|
||||
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
|
||||
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
@@ -168,7 +168,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
viewerArea.append(scaleBar);
|
||||
|
||||
// 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다.
|
||||
const compass = createTerrainCompass();
|
||||
const compass = createTerrainCompass({ className: "b04-surface__compass" });
|
||||
viewerArea.append(compass.root);
|
||||
|
||||
// Elevation bounds legend bar overlay (I-403)
|
||||
|
||||
@@ -153,11 +153,13 @@ def design_ground_following_profile(
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""전체 측점을 변화점으로 삼고 계획고를 원지반고에 맞추는 1차 계획선.
|
||||
|
||||
규칙(2026-09-02 사용자 확정):
|
||||
- **모든 측점이 변화점**이고 그 표고는 그 자리 지반고 그대로다. 절·성토가 거의 0이다.
|
||||
- **종단곡선(라운드)은 자동으로 넣지 않는다.** 사용자가 [직선화]로 만든 직선을
|
||||
틸팅할 때 그 변화점에만 R이 지정되고, 그때 곡선이 생긴다
|
||||
(`build_alignment(only_explicit_curves=True)`).
|
||||
규칙(2026-09-02 사용자 확정 + 2026-09-03 종단곡선 복원):
|
||||
- **모든 측점이 변화점**이고 그 자리 계획고는 지반고 그대로다. 절·성토가 거의 0이다.
|
||||
- **변화점마다 종단곡선을 넣는다.** 직선과 직선 사이에는 반드시 호가 있고, 호는
|
||||
변화점 대칭이라 **호의 중심이 측점 세로선 위**에 놓이며 좌우 직선과 접선을 이룬다.
|
||||
- 대칭 종단곡선은 꼭짓점을 지나지 않으므로 **호와 측점 세로선의 교점이 지반고**가
|
||||
되도록 꼭짓점 표고를 중앙종거만큼 밀어내며 반복 보정한다
|
||||
(`design_pipe_anchored_profile` 과 같은 방식).
|
||||
- 기울기는 지형 그대로라 법정 상한을 넘길 수 있다 — 막지 않고 경고만 남긴다
|
||||
(기존 정책과 같다).
|
||||
|
||||
@@ -204,6 +206,19 @@ def design_ground_following_profile(
|
||||
if note:
|
||||
warnings.append(note)
|
||||
|
||||
# 대칭 종단곡선은 꼭짓점을 지나지 않는다(중앙종거 |A|·L/8) — 보정 없이 곡선을 넣으면
|
||||
# 계획고가 지반고에서 뜬다. 호가 측점 세로선과 만나는 점이 지반고가 되도록 꼭짓점을
|
||||
# 밀어내며, 표고가 움직이면 대수차도 변하므로 수렴할 때까지 되풀이한다.
|
||||
target = base_z.copy()
|
||||
for _ in range(12):
|
||||
curves, _curve_warnings = build_curves(base_s, base_z, policy)
|
||||
error = target - evaluate(base_s, base_z, curves, base_s)
|
||||
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,
|
||||
base_z=base_z,
|
||||
@@ -212,7 +227,6 @@ def design_ground_following_profile(
|
||||
stations=stations,
|
||||
policy=policy,
|
||||
edits=dict(edits or {}),
|
||||
only_explicit_curves=True,
|
||||
)
|
||||
warnings.extend(alignment["warnings"])
|
||||
balanced = bool(alignment["balance"]["within_tolerance"])
|
||||
@@ -449,10 +463,6 @@ def rebuild_alignment_profile(
|
||||
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
||||
|
||||
previous = (longitudinal.get("design_profiles") or [{}])[0]
|
||||
# 전체 측점 폴리라인은 곡선을 자동으로 넣지 않는다 — 재구성도 같은 규칙이어야
|
||||
# 편집 델타를 지웠을 때 초기 폴리라인으로 정확히 돌아간다(2026-09-02).
|
||||
only_explicit = str(previous.get("basis") or "") == GROUND_POLYLINE_BASIS
|
||||
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
@@ -461,7 +471,6 @@ def rebuild_alignment_profile(
|
||||
stations=list(longitudinal.get("stations") or []),
|
||||
policy=policy,
|
||||
edits=edits,
|
||||
only_explicit_curves=only_explicit,
|
||||
)
|
||||
criteria = previous.get("criteria") or {}
|
||||
options = GradeDesignOptions(
|
||||
|
||||
@@ -30,6 +30,10 @@ import { createSelectionSync } from "./B05_Profile_UI_Selection";
|
||||
import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
|
||||
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
|
||||
import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
rememberRockBoundaryDefault,
|
||||
rememberStandardDefaults,
|
||||
} from "../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
fetchSectionContext,
|
||||
type SectionDetailResponse,
|
||||
@@ -632,6 +636,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
haulLimits: sectionContext.haul_equipment_limits,
|
||||
naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined,
|
||||
});
|
||||
// 표준횡단 config 기본값도 같은 방식으로 기억한다 — B05에는 「표준 횡단면 설정」
|
||||
// 패널이 없지만, 계획선 편집 중 횡단 재계산이 브라우저에서 돌아 이 값이 필요하다
|
||||
// (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다.
|
||||
rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section);
|
||||
rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m);
|
||||
restorePanel(latestResponse);
|
||||
renderLatest(latestResponse);
|
||||
latest = latestResponse;
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*
|
||||
* 표시 항목(별표2 근거):
|
||||
* 최대 기울기 x.x% (상한 y%) — Ⅰ.2.라: 설계속도·지형별 상한 준수 판정
|
||||
* 불균형 z% / 허용 w% — Ⅰ.1.나.(4)(다): 시공계획고 절·성토 균형
|
||||
* 곡선 필요 n곳 — Ⅰ.2.마: 대수차 5% 초과 변화점의 종단곡선 삽입
|
||||
* 절·성토 면적은 하단 유토곡선이 부피로 더 정확히 보여 주고, 변화점·종단곡선 개수와
|
||||
* 절·성토 균형은 **하단 유토곡선**이 횡단 기준 부피(㎥)로 보여 준다 — 여기 있던 종단 기준
|
||||
* 불균형(㎡ 비율)은 실제 토량이 아니라 의미가 없어 뺐다(2026-09-03). 변화점·종단곡선 개수와
|
||||
* 기본 곡선길이 L은 판단 기준이 없어 뺐다(2026-08-19 사용자 지적 + 지식DB 대조).
|
||||
* [저장선 복원]은 상단에서 내려 그래프 조작부로 옮겼다.
|
||||
*
|
||||
@@ -53,7 +53,7 @@ export function renderBalanceBar(params: BalanceBarParams): void {
|
||||
return;
|
||||
}
|
||||
const { alignment } = params;
|
||||
const { balance, policy, violations } = alignment;
|
||||
const { policy, violations } = alignment;
|
||||
// 최대 종단기울기 — 법정 상한 대비가 이 화면의 첫 판정 항목이다(별표2 Ⅰ.2.라).
|
||||
const steepest = alignment.segments.reduce(
|
||||
(worst, segment) => Math.max(worst, Math.abs(segment.grade_percent)),
|
||||
@@ -70,12 +70,10 @@ export function renderBalanceBar(params: BalanceBarParams): void {
|
||||
`${steepest.toFixed(1)} % / 상한 ${policy.max_grade_pct.toFixed(1)} %`,
|
||||
violations.length ? "over" : undefined,
|
||||
],
|
||||
[
|
||||
"절·성토 불균형",
|
||||
`${balance.imbalance_percent.toFixed(1)} % / 허용 ${balance.tolerance_percent.toFixed(0)} %`,
|
||||
balance.within_tolerance ? undefined : "over",
|
||||
],
|
||||
];
|
||||
// 절·성토 불균형은 2026-09-03 사용자 지시로 뺐다. 이 값은 **종단 기준**이었다 —
|
||||
// 계획선과 지반선 사이 세로 면적(㎡)의 절·성토 비이지 실제 토량이 아니다. 의미 있는
|
||||
// 균형은 횡단 단면적을 쌓아 부피로 내는 하단 유토곡선 요약(㎥)이고, 그쪽이 이미 있다.
|
||||
// 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다.
|
||||
if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]);
|
||||
// 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은
|
||||
|
||||
@@ -63,8 +63,13 @@ function createHoldRepeater(): { start: (action: () => void) => void; stop: () =
|
||||
export interface ProfileEditStore {
|
||||
edits(): AlignmentEdits;
|
||||
replace(next: AlignmentEdits): void;
|
||||
/** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. */
|
||||
/** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다.
|
||||
* 이때의 값이 [초기화]의 기준점이 된다. */
|
||||
markSaved(): void;
|
||||
/** 마지막 [저장] 시점으로 되돌린다 — 그 뒤의 편집만 버린다(2026-09-03 사용자 지시). */
|
||||
restoreSaved(): void;
|
||||
/** 마지막 저장 시점과 지금이 다른가([초기화] 활성 조건). */
|
||||
canRestoreSaved(): boolean;
|
||||
resetStation(chainageM: number): void;
|
||||
resetAll(): void;
|
||||
/** 아직 서버에 반영되지 않은 변경이 있는가. */
|
||||
@@ -114,6 +119,12 @@ export function createProfileEditStore(
|
||||
const draft = readDraft(storageKey);
|
||||
let current = draft ?? saved;
|
||||
let unsaved = draft !== null;
|
||||
/**
|
||||
* [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시).
|
||||
* 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동
|
||||
* 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다.
|
||||
*/
|
||||
let savedBaseline = saved;
|
||||
|
||||
function dropDraft(): void {
|
||||
try {
|
||||
@@ -139,8 +150,16 @@ export function createProfileEditStore(
|
||||
replace: commit,
|
||||
markSaved() {
|
||||
unsaved = false;
|
||||
savedBaseline = current;
|
||||
dropDraft();
|
||||
},
|
||||
restoreSaved() {
|
||||
current = savedBaseline;
|
||||
unsaved = false;
|
||||
dropDraft();
|
||||
onChange();
|
||||
},
|
||||
canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline),
|
||||
resetStation(chainageM) {
|
||||
const key = chainageKey(chainageM);
|
||||
const stationOffsets = { ...current.station_offsets };
|
||||
@@ -174,6 +193,14 @@ export interface EditOverlayOptions {
|
||||
onStation: (chainageM: number, delta: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 측점 목록이 **같은 측점**을 가리키는 것으로 볼 누가거리 차(m).
|
||||
*
|
||||
* 사이드바 구조물 목록은 표기 규칙(`n+ 0.0`)대로 0.1m 로 반올림한 값을 들고 있어 종단
|
||||
* 정본의 원값과 최대 0.05m 어긋난다. 실제로 이만큼 붙은 별개 측점은 없다.
|
||||
*/
|
||||
const SAME_STATION_TOLERANCE_M = 0.1;
|
||||
|
||||
function overlayButton(
|
||||
repeater: { start: (action: () => void) => void },
|
||||
className: string,
|
||||
@@ -241,19 +268,28 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
||||
* 측점선 자체는 그대로라 버튼만 살짝 비켜선다.
|
||||
*
|
||||
* 확정을 거친 구조물 측점은 종단 정본(alignment.stations)에 **이미 병합**돼 있어
|
||||
* 사이드바 목록(irregular)과 이중으로 잡힌다 — 같은 chainage(소수 3자리)는 한 번만
|
||||
* 버튼을 단다(2026-08-04 사용자 보고: 구조물 측점 버튼이 2개씩 생김).
|
||||
* 사이드바 목록(irregular)과 이중으로 잡힌다 — 겹치면 한 번만 버튼을 단다
|
||||
* (2026-08-04 사용자 보고: 구조물 측점 버튼이 2개씩 생김).
|
||||
*
|
||||
* 겹침 판정은 **거리**로 한다. 두 목록의 누가거리가 소수 3자리까지 같지 않기 때문이다 —
|
||||
* 종단 정본은 원값(`85.594514`), 사이드바 목록은 표기 규칙대로 0.1m 로 반올림한 값
|
||||
* (`85.6`)을 들고 있어 키 비교로는 서로 다른 측점으로 잡혔다. 그 결과 버튼이 두 벌 생기고,
|
||||
* 사이드바 쪽 버튼을 누르면 정본 측점에서 몇 mm 떨어진 **가짜 변화점**이 승격돼 그 구간
|
||||
* 기울기가 수천 %로 튀었다(2026-09-03 사용자 보고 · 실측 11,858%).
|
||||
*/
|
||||
const alignmentStationKeys = new Set(
|
||||
alignment.stations.map((station) => chainageKey(station.chainage_m)),
|
||||
);
|
||||
const stationPlacements = [
|
||||
...alignment.stations.map((station) => ({
|
||||
chainage: station.chainage_m,
|
||||
plan: station.plan_elevation_m,
|
||||
})),
|
||||
...irregular
|
||||
.filter((station) => !alignmentStationKeys.has(chainageKey(station.chainage_m)))
|
||||
.filter(
|
||||
(station) =>
|
||||
!alignment.stations.some(
|
||||
(merged) =>
|
||||
Math.abs(merged.chainage_m - station.chainage_m) <= SAME_STATION_TOLERANCE_M,
|
||||
),
|
||||
)
|
||||
.map((station) => ({
|
||||
chainage: station.chainage_m,
|
||||
plan: planElevationAtSample(alignment, station.chainage_m),
|
||||
|
||||
@@ -127,7 +127,7 @@ export interface RouteMassHaulDrawParams {
|
||||
/**
|
||||
* 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛
|
||||
* 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정:
|
||||
* 「새 값만 보여주기」). Panel이 `staleDesignChainages`로 판정해 넘긴다.
|
||||
* 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다.
|
||||
*/
|
||||
pendingRecalc?: boolean;
|
||||
}
|
||||
@@ -293,7 +293,11 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
mirror(scroll, main);
|
||||
}
|
||||
|
||||
/** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */
|
||||
let drawnOnce = false;
|
||||
|
||||
function note(message: string): void {
|
||||
drawnOnce = false;
|
||||
bar.replaceChildren();
|
||||
scroll.replaceChildren();
|
||||
legendLayer.replaceChildren();
|
||||
@@ -312,11 +316,25 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
// 저장된 횡단이 지금 계획선과 어긋나 있으면 그 면적은 **옛 계획고로 만든 값**이다.
|
||||
// 그걸 잠깐 그렸다가 재계산 결과로 갈아 끼우면 사용자가 옛 그림을 본다(2026-09-03
|
||||
// 사용자 확정: 「새 값만 보여주기」). Panel이 이미 재계산을 예약해 두므로 기다린다.
|
||||
// 저장된 횡단이 지금 계획선과 어긋나 있으면 그 면적은 옛 계획고로 만든 값이다 —
|
||||
// 새 값이 올 때까지 기다린다(2026-09-03 사용자 확정: 「새 값만 보여주기」).
|
||||
//
|
||||
// 다만 **곡선을 지우지는 않는다**. 계획고를 연속으로 누르면(초당 10회) 매번 곡선이
|
||||
// 사라졌다 다시 서서 화면이 깜빡였다(2026-09-03 사용자 보고). 재계산은 브라우저
|
||||
// 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만
|
||||
// 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다.
|
||||
if (params.pendingRecalc) {
|
||||
note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.");
|
||||
if (drawnOnce) {
|
||||
const pending = bar.querySelector(".b05-profile__masshaul-pending");
|
||||
if (!pending) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "b05-profile__masshaul-pending";
|
||||
chip.textContent = "다시 계산 중…";
|
||||
bar.append(chip);
|
||||
}
|
||||
} else {
|
||||
note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
|
||||
@@ -386,6 +404,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
onChanged();
|
||||
}),
|
||||
);
|
||||
drawnOnce = true;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
createProfileTableOverlay,
|
||||
TABLE_OVERLAY_MIN_HEIGHT,
|
||||
} from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
import { hasLegacyAlignment, readAlignment } from "./B05_Profile_UI_Profile_Data";
|
||||
import { hasLegacyAlignment, readAlignment, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { saveProfileAlignment } from "./B05_Profile_Api_Fetch";
|
||||
import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { hasStaleDesigns } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import {
|
||||
blocksMinCover,
|
||||
findMinCoverViolations,
|
||||
@@ -80,9 +80,11 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
const MIN_PANEL_HEIGHT = 180;
|
||||
/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
/** 계획고 편집 후 횡단 재계산을 서버에 묻기까지 기다리는 시간(ms).
|
||||
* ▲/▼ 길게 누르기(초당 10회)로 요청이 쏟아지지 않게 마지막 값만 보낸다. */
|
||||
const CROSS_PREVIEW_DEBOUNCE_MS = 250;
|
||||
/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms).
|
||||
* ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다.
|
||||
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
|
||||
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. */
|
||||
const CROSS_PREVIEW_DEBOUNCE_MS = 60;
|
||||
|
||||
/**
|
||||
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
|
||||
@@ -387,6 +389,8 @@ export function createRouteProfilePanel(
|
||||
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||||
rebuild();
|
||||
},
|
||||
restoreSaved: () => store.restoreSaved(),
|
||||
canRestoreSaved: () => store.canRestoreSaved(),
|
||||
refresh: () => renderBalance(),
|
||||
});
|
||||
|
||||
@@ -503,6 +507,7 @@ export function createRouteProfilePanel(
|
||||
applyEdits,
|
||||
handleToolPick,
|
||||
toolActive: () => tools.mode() !== "none",
|
||||
selectedRuns: () => tools.selectedRuns(),
|
||||
stationIdAtStructure,
|
||||
redraw: draw,
|
||||
});
|
||||
@@ -582,7 +587,23 @@ export function createRouteProfilePanel(
|
||||
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
|
||||
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
|
||||
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
|
||||
if (staleDesignChainages(nextDetail).length) scheduleCrossPreview();
|
||||
//
|
||||
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
|
||||
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
|
||||
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
|
||||
// (2026-09-03 사용자 보고 — 편집 측점 2개가 세션에 남은 상태에서 재현).
|
||||
if (
|
||||
hasStaleDesigns({
|
||||
longitudinal: {
|
||||
design_profiles: alignment
|
||||
? [toDesignProfile(alignment, nextDetail.longitudinal.design_profiles?.[0])]
|
||||
: nextDetail.longitudinal.design_profiles,
|
||||
},
|
||||
cross_sections: nextDetail.cross_sections,
|
||||
})
|
||||
) {
|
||||
scheduleCrossPreview();
|
||||
}
|
||||
},
|
||||
/** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23).
|
||||
* 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Prof
|
||||
import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools";
|
||||
import {
|
||||
detectStraightRun,
|
||||
hasStraightRuns,
|
||||
shiftStraightRuns,
|
||||
straightenBetween,
|
||||
tiltStraightRun,
|
||||
@@ -53,6 +54,10 @@ export interface PanelToolsContext {
|
||||
};
|
||||
/** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */
|
||||
restore: () => void;
|
||||
/** 마지막 [저장] 시점으로 편집을 되돌린다(스토어 소관). */
|
||||
restoreSaved: () => void;
|
||||
/** 되돌릴 것이 남아 있는가 — [초기화] 버튼 활성 조건. */
|
||||
canRestoreSaved: () => boolean;
|
||||
/** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */
|
||||
refresh: () => void;
|
||||
}
|
||||
@@ -114,10 +119,20 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
if (!base) return;
|
||||
ctx.applyEdits(tiltStraightRun(base, ctx.edits(), run, delta));
|
||||
},
|
||||
onResetToSaved: () => {
|
||||
// 이력에 남겨 [↶]로 되살릴 수 있게 한다 — 잘못 눌러도 잃는 것이 없다.
|
||||
ctx.restoreSaved();
|
||||
history.record();
|
||||
},
|
||||
canResetToSaved: ctx.canRestoreSaved,
|
||||
onUndo: () => inner.undo(),
|
||||
onRedo: () => inner.redo(),
|
||||
canUndo: () => inner.canUndo(),
|
||||
canRedo: () => inner.canRedo(),
|
||||
hasStraightRuns: () => {
|
||||
const alignment = ctx.alignment();
|
||||
return alignment ? hasStraightRuns(alignment) : false;
|
||||
},
|
||||
onChanged: ctx.refresh,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
} from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import { LONG_PAD, staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { hasStaleDesigns, LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
type ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit";
|
||||
import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight";
|
||||
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover";
|
||||
import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { createProfileTable } from "./B05_Profile_UI_Profile_Table";
|
||||
@@ -83,6 +85,8 @@ export interface ProfileRenderContext {
|
||||
handleToolPick: (chainageM: number | null) => boolean;
|
||||
/** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */
|
||||
toolActive: () => boolean;
|
||||
/** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */
|
||||
selectedRuns: () => StraightRun[];
|
||||
stationIdAtStructure: (structureId: string | null) => string | null;
|
||||
/** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */
|
||||
redraw: () => void;
|
||||
@@ -286,6 +290,18 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트
|
||||
// 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙).
|
||||
if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight));
|
||||
// 쉬프트로 고른 구간 강조 — 편집 버튼층보다 아래에 깔아 버튼을 가리지 않는다.
|
||||
if (alignment) {
|
||||
const highlight = createRunHighlight({
|
||||
alignment,
|
||||
runs: ctx.selectedRuns(),
|
||||
x,
|
||||
axis: yAxis,
|
||||
widthPx: width,
|
||||
heightPx: chartHeight,
|
||||
});
|
||||
if (highlight) chartWrap.append(highlight);
|
||||
}
|
||||
if (alignment) {
|
||||
chartWrap.append(
|
||||
createEditOverlay({
|
||||
@@ -326,9 +342,15 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
|
||||
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
|
||||
crossSections: detail.cross_sections,
|
||||
// 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 —
|
||||
// Panel이 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정).
|
||||
pendingRecalc: staleDesignChainages(detail).length > 0,
|
||||
// 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — Panel이
|
||||
// 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정).
|
||||
// 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분
|
||||
// (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로
|
||||
// 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고).
|
||||
pendingRecalc: hasStaleDesigns({
|
||||
longitudinal: { design_profiles: designProfiles },
|
||||
cross_sections: detail.cross_sections,
|
||||
}),
|
||||
axis: {
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_RunHighlight.ts
|
||||
* [쉬프트]로 고른 직선 구간 강조 오버레이 (2026-09-03 사용자 지시).
|
||||
*
|
||||
* 고른 직선을 눌러도 화면에 아무 표시가 없어 무엇이 잡혔는지 알 수 없었다. 강조 범위는
|
||||
* **직선 + 양 끝 라운드(R)** 다 — 쉬프트는 직선을 통째로 올리고 내리므로 양 끝 호의
|
||||
* 모양까지 함께 바뀌기 때문이다. 그래서 구간 시작 라운드의 BVC부터 끝 라운드의 EVC까지
|
||||
* 계획선 위를 그대로 덧그린다.
|
||||
*
|
||||
* 종단 그래프 SVG(`B06_Section_UI_Longitudinal`)는 B05·B06 공용이라 건드리지 않고,
|
||||
* 같은 좌표계 위에 별도 오버레이를 얹는다. Y 매핑은 그 렌더러가 넘겨 준 축 눈금
|
||||
* (`onYAxis`)에서 되짚는다 — 두 눈금의 (표고, y) 두 쌍이면 1차식이 정해진다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 두 chainage를 같은 변화점으로 볼 허용 오차(m). */
|
||||
const SAME_NODE_M = 1e-6;
|
||||
|
||||
export interface RunHighlightOptions {
|
||||
alignment: ProfileAlignment;
|
||||
/** 강조할 구간들(쉬프트 선택분). 비어 있으면 오버레이를 만들지 않는다. */
|
||||
runs: StraightRun[];
|
||||
/** 누가거리 → 화면 x(px). 종단 그래프와 같은 매핑이어야 선이 겹친다. */
|
||||
x: (chainageM: number) => number;
|
||||
/** 종단 렌더러가 넘겨 준 Y축 눈금 — 표고 → y(px) 를 되짚는 근거. */
|
||||
axis: { ticks: Array<{ y: number; label: string }> } | null;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
}
|
||||
|
||||
/** 눈금 라벨(`880m`)에서 표고를 읽는다. 숫자가 아니면 null. */
|
||||
function tickElevation(label: string): number | null {
|
||||
const value = Number.parseFloat(label);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 축 눈금 두 개로 표고 → y(px) 1차식을 만든다. 눈금이 모자라거나 겹치면 null.
|
||||
* (렌더러와 같은 스케일을 쓰려는 것이므로 별도로 계산하지 않는다.)
|
||||
*/
|
||||
function elevationToY(
|
||||
axis: { ticks: Array<{ y: number; label: string }> } | null,
|
||||
): ((elevationM: number) => number) | null {
|
||||
const points = (axis?.ticks ?? [])
|
||||
.map((tick) => ({ y: tick.y, elevation: tickElevation(tick.label) }))
|
||||
.filter((entry): entry is { y: number; elevation: number } => entry.elevation !== null);
|
||||
if (points.length < 2) return null;
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
const span = last.elevation - first.elevation;
|
||||
if (Math.abs(span) < 1e-9) return null;
|
||||
const scale = (last.y - first.y) / span;
|
||||
return (elevationM: number): number => first.y + (elevationM - first.elevation) * scale;
|
||||
}
|
||||
|
||||
/** 구간 양 끝 라운드까지 넓힌 강조 범위 [시작, 끝] (누가거리 m). */
|
||||
function runSpanWithCurves(alignment: ProfileAlignment, run: StraightRun): [number, number] {
|
||||
const startCurve = alignment.curves.find(
|
||||
(curve) => Math.abs(curve.chainage_m - run.fromM) <= SAME_NODE_M && !curve.omitted,
|
||||
);
|
||||
const endCurve = alignment.curves.find(
|
||||
(curve) => Math.abs(curve.chainage_m - run.toM) <= SAME_NODE_M && !curve.omitted,
|
||||
);
|
||||
return [startCurve ? startCurve.bvc_m : run.fromM, endCurve ? endCurve.evc_m : run.toM];
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 구간을 계획선 위에 빨갛게 덧그린 오버레이. 고른 것이 없으면 null.
|
||||
* 반환한 요소는 종단 차트 래퍼(`b05-profile__chart`) 안에 그대로 붙이면 된다.
|
||||
*/
|
||||
export function createRunHighlight(options: RunHighlightOptions): SVGElement | null {
|
||||
const { alignment, runs, x, widthPx, heightPx } = options;
|
||||
if (!runs.length) return null;
|
||||
const toY = elevationToY(options.axis);
|
||||
if (!toY) return null;
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("class", "b05-profile-runmark");
|
||||
svg.setAttribute("width", String(widthPx));
|
||||
svg.setAttribute("height", String(heightPx));
|
||||
svg.setAttribute("viewBox", `0 0 ${widthPx} ${heightPx}`);
|
||||
|
||||
const samples = alignment.samples;
|
||||
for (const run of runs) {
|
||||
const [fromM, toM] = runSpanWithCurves(alignment, run);
|
||||
// 계획선 샘플에는 변화점·BVC·EVC가 모두 들어 있어 라운드 곡률까지 그대로 따라온다.
|
||||
const points = samples
|
||||
.filter((sample) => sample.chainage_m >= fromM - SAME_NODE_M)
|
||||
.filter((sample) => sample.chainage_m <= toM + SAME_NODE_M)
|
||||
.map((sample) => `${x(sample.chainage_m).toFixed(2)},${toY(sample.elevation_m).toFixed(2)}`);
|
||||
if (points.length < 2) continue;
|
||||
const line = document.createElementNS(SVG_NS, "polyline");
|
||||
line.setAttribute("class", "b05-profile-runmark__line");
|
||||
line.setAttribute("points", points.join(" "));
|
||||
svg.append(line);
|
||||
// 양 끝 표시 — 어디까지가 이 구간인지(라운드 포함) 한눈에 보이게 세로 표식을 둔다.
|
||||
for (const edge of [fromM, toM]) {
|
||||
const tick = document.createElementNS(SVG_NS, "line");
|
||||
tick.setAttribute("class", "b05-profile-runmark__edge");
|
||||
tick.setAttribute("x1", x(edge).toFixed(2));
|
||||
tick.setAttribute("x2", x(edge).toFixed(2));
|
||||
tick.setAttribute("y1", "0");
|
||||
tick.setAttribute("y2", String(heightPx));
|
||||
svg.append(tick);
|
||||
}
|
||||
}
|
||||
return svg.childElementCount ? svg : null;
|
||||
}
|
||||
@@ -114,6 +114,22 @@ export function detectStraightRun(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 계획선에 **직선화된 구간이 하나라도 있는가** — [쉬프트] 안내 문구용.
|
||||
*
|
||||
* 초기 계획선은 모든 측점이 변화점이라 마디마다 기울기가 달라 직선 구간이 없다. 그때
|
||||
* [쉬프트]로 아무 데나 눌러도 잡히는 것이 없어 「선택이 안 된다」로 보였다
|
||||
* (2026-09-03 사용자 보고). 안내를 나누려면 이 판정이 필요하다.
|
||||
*/
|
||||
export function hasStraightRuns(alignment: ProfileAlignment): boolean {
|
||||
for (let index = 1; index < alignment.segments.length; index += 1) {
|
||||
if (Math.abs(gradeAt(alignment, index) - gradeAt(alignment, index - 1)) < COLLINEAR_EPSILON) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
|
||||
function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] {
|
||||
return pviChainages(alignment).filter(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Tools.ts
|
||||
* 종단 계획선 도구줄 — [↶][↷] · [직선화] · [쉬프트] · [▲][▼] (2026-09-02 사용자 지시).
|
||||
* 종단 계획선 도구줄 — [초기화] · [↶][↷] · [직선화] · [쉬프트] · [▲][▼].
|
||||
* ([초기화]는 2026-09-03 추가 — 마지막 [저장] 시점 기준이며 전체 초기화가 아니다.)
|
||||
*
|
||||
* 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 그래프 위
|
||||
* 틸팅 버튼과 같다(`b05-profile-edit__btn` 계열 크기를 CSS에서 공유).
|
||||
@@ -24,10 +25,15 @@ export interface ProfileToolsCallbacks {
|
||||
onShift: (runs: StraightRun[], delta: number) => void;
|
||||
/** 고른 직선 구간을 꺾는다 — 가운데 라운드 + 양측 탄젠트. */
|
||||
onTilt: (run: StraightRun, delta: number) => void;
|
||||
/** 마지막 [저장] 시점으로 되돌린다 — 전체 초기화가 아니다. */
|
||||
onResetToSaved: () => void;
|
||||
canResetToSaved: () => boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
/** 지금 계획선에 직선화된 구간이 있는가 — [쉬프트] 안내 문구를 가른다. */
|
||||
hasStraightRuns: () => boolean;
|
||||
/** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */
|
||||
onChanged: () => void;
|
||||
}
|
||||
@@ -138,6 +144,14 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "b05-route-profile__tools";
|
||||
|
||||
// [초기화] — 되돌리기 왼쪽. 기준은 **마지막 [저장] 시점**이고 저장한 작업은 남는다.
|
||||
const reset = toolButton(
|
||||
"초기화",
|
||||
"마지막 [저장] 시점으로 되돌립니다 (저장한 작업은 그대로 남습니다)",
|
||||
callbacks.onResetToSaved,
|
||||
);
|
||||
reset.disabled = !callbacks.canResetToSaved();
|
||||
|
||||
const undo = toolButton("↶", "되돌리기 (Ctrl+Z)", callbacks.onUndo);
|
||||
undo.disabled = !callbacks.canUndo();
|
||||
const redo = toolButton("↷", "다시하기 (Ctrl+Shift+Z)", callbacks.onRedo);
|
||||
@@ -162,16 +176,20 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
up.disabled = idle || (mode === "straighten" && runs.length !== 1);
|
||||
down.disabled = up.disabled;
|
||||
|
||||
wrap.append(undo, redo, straighten, shift, up, down);
|
||||
wrap.append(reset, undo, redo, straighten, shift, up, down);
|
||||
if (mode === "straighten" && pending !== null) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요`;
|
||||
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`;
|
||||
wrap.append(hint);
|
||||
} else if (mode === "shift" && !runs.length) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = "직선화된 라인을 고르세요";
|
||||
// 고를 것이 아예 없는 경우와 있는데 안 고른 경우를 갈라 적는다 — 초기 계획선은
|
||||
// 마디마다 기울기가 달라 직선 구간이 없어, 눌러도 안 잡히는 것이 정상이다.
|
||||
hint.textContent = callbacks.hasStraightRuns()
|
||||
? "직선화된 라인을 고르세요 (여러 개 가능) — 끝내려면 [쉬프트]를 다시 누르세요"
|
||||
: "직선화된 구간이 없습니다 — [직선화]로 먼저 만드세요";
|
||||
wrap.append(hint);
|
||||
}
|
||||
return wrap;
|
||||
|
||||
@@ -192,6 +192,14 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 방위 나침반을 놓을 자리 — ISO 버튼 바로 아래(2026-09-03 사용자 지시).
|
||||
버튼 줄 높이 34px + 위 여백 16px 아래에 붙인다. 위젯 모양·색은 공용
|
||||
(`ui_template/ui_template_compass.css`)이고 여기서는 위치만 준다. */
|
||||
.b05-route__compass {
|
||||
top: calc(var(--spacing-16) + 34px + var(--spacing-8));
|
||||
left: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b05-route__view-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
@@ -927,6 +935,32 @@
|
||||
}
|
||||
|
||||
/* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */
|
||||
/* [쉬프트]로 고른 직선 구간 강조 — 직선과 **양 끝 라운드**를 함께 빨갛게 덧그린다
|
||||
(2026-09-03 사용자 지시). 편집 버튼층(z-index 4)보다 아래에 깔아 버튼을 가리지 않는다. */
|
||||
.b05-profile-runmark {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile-runmark__line {
|
||||
fill: none;
|
||||
stroke: var(--color-danger, #d64545);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 구간 경계(라운드 시·종점) 세로 표식 — 어디까지 함께 움직이는지 알린다. */
|
||||
.b05-profile-runmark__edge {
|
||||
stroke: var(--color-danger, #d64545);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 3;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.b05-profile-edit {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
|
||||
@@ -112,6 +112,14 @@
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* 재계산 대기 표시 — 곡선을 지우지 않고 요약줄 끝에만 붙인다(2026-09-03 깜빡임 제거). */
|
||||
.b05-profile__masshaul-pending {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-empty {
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { createTerrainCompass } from "@ui/ui_template_compass";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -125,7 +126,10 @@ export function createRouteViewer(): RouteViewer {
|
||||
status.className = "b05-route__viewer-status";
|
||||
status.textContent = "확정 지표면을 불러오는 중입니다.";
|
||||
const canvas = document.createElement("canvas");
|
||||
root.append(canvas, status);
|
||||
// 방위 나침반 — ISO 버튼 아래(2026-09-03 사용자 지시). 지면에 누운 링이라 사시도에서도
|
||||
// 화면 북쪽과 어긋나지 않는다. 위젯은 B04 지표면 뷰어와 **같은 공용 모듈**을 쓴다.
|
||||
const compass = createTerrainCompass({ sizePx: 104, className: "b05-route__compass" });
|
||||
root.append(canvas, status, compass.root);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const systemDarkTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
@@ -320,6 +324,17 @@ export function createRouteViewer(): RouteViewer {
|
||||
function animate(): void {
|
||||
frame = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
// 나침반은 시선 방향이 실제로 바뀔 때만 다시 그린다(모듈 안에서 걸러낸다).
|
||||
if (terrain) {
|
||||
compass.setVisible(true);
|
||||
compass.update(
|
||||
camera.position.x - controls.target.x,
|
||||
camera.position.y - controls.target.y,
|
||||
camera.position.z - controls.target.z,
|
||||
);
|
||||
} else {
|
||||
compass.setVisible(false);
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
@@ -2,24 +2,44 @@
|
||||
* B06_Section_Cross_Refresh.ts
|
||||
* 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다.
|
||||
*
|
||||
* ── 왜 필요한가 (2026-09-03 사용자 지시) ─────────────────────────────
|
||||
* 유토곡선은 두 화면이 같은 데이터를 보여 주는 기능인데, 재계산 호출이 두 벌이라 값이
|
||||
* 갈렸다. 실측 — 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥` ↔ B05 `4,526.8㎥`.
|
||||
* 원인은 인자였다:
|
||||
* · B06: `previewCrossDesigns(..., standardPanel.getValues(), { fullDesigns, rockBoundaryOffsets })`
|
||||
* · B05: `previewCrossDesigns(..., undefined, { fullDesigns })`
|
||||
* 표준 단면값과 암 경계 오프셋이 빠지면 서버가 다른 설계를 그려 단면적이 달라진다.
|
||||
* 보존하는 사용자 부속값 목록도 서로 달라(B05는 2개, B06은 7개) B05를 거치면 기슭막이
|
||||
* 조정 같은 값이 사라졌다.
|
||||
* ── 계산은 브라우저 안에서 끝난다 (2026-09-03 사용자 확정) ───────────
|
||||
* 사용자 조작 중의 계산은 서버로 나가지 않는다. 조작은 세션 캐시에 쌓이고 화면은 즉시
|
||||
* 따라오며, 영구저장소는 [저장]·[확정]에서만 건드린다. 예전에는 계획고가 바뀔 때마다
|
||||
* `POST …/cross-design/preview` 로 전 측점 횡단을 서버에 물어, 왕복이 조작 속도를
|
||||
* 지배했다(2026-09-03 사용자 보고: 「종단을 바꾸면 업데이트가 느리다」).
|
||||
*
|
||||
* 그래서 **입력 수집·서버 호출·제자리 반영**을 여기 한 곳으로 모은다. 세션 편집값은
|
||||
* 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 B06과 같은 값을 보낸다.
|
||||
* 그래서 설계 계산은 `common_util_cross_design.ts`(파이썬 `B06_Section_Engine_Design.py`
|
||||
* 의 미러)로 옮겼고, 여기서는 **입력을 모아 전 측점을 돌리고 제자리 반영**만 한다.
|
||||
* 서버 프리뷰는 선형 저장분이 없어 계획고를 못 푸는 **옛 데이터 폴백**으로만 남는다.
|
||||
*
|
||||
* ⚠ 두 벌 계산 주의 — TS 미러(`common_util_cross_design*.ts`)와 파이썬 엔진
|
||||
* (`B06_Section_Engine_Design.py`·`B06_Section_Engine_Areas.py`)은 한 벌이다.
|
||||
* 한쪽만 고치면 화면과 저장본이 갈린다. 회귀 테스트:
|
||||
* `tmp/tests/test_b06_cross_design_mirror.py`
|
||||
*
|
||||
* ── 왜 창구가 하나여야 하는가 (2026-09-03 실측) ──────────────────────
|
||||
* 재계산 호출이 두 벌이던 시절, 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥`
|
||||
* ↔ B05 `4,526.8㎥` 로 갈렸다. 원인은 인자였다 — 표준 단면값과 암 경계 오프셋이 빠지면
|
||||
* 서버가 다른 설계를 그린다. 입력 수집·계산·제자리 반영을 여기 한 곳에 모아 둔다. 세션
|
||||
* 편집값은 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 같은 값을 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
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 { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
effectiveStandardCross,
|
||||
readRockBoundaryDefault,
|
||||
readStandardCrossSession,
|
||||
} from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
buildAlignment,
|
||||
planElevationAt,
|
||||
toAlignmentBase,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
|
||||
|
||||
/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */
|
||||
export interface CrossRefreshEdits {
|
||||
@@ -34,28 +54,39 @@ export interface CrossRefreshInput {
|
||||
detail: SectionDetailResponse;
|
||||
edits: CrossRefreshEdits;
|
||||
/**
|
||||
* 응답이 도착한 시점에 **아직 이 결과를 써도 되는지** 묻는다(false면 반영하지 않는다).
|
||||
* 끌기 중에는 요청이 겹치므로, 늦게 온 옛 응답이 새 설계를 덮지 않게 하는 문지기다.
|
||||
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
|
||||
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
|
||||
*/
|
||||
shouldApply?: () => boolean;
|
||||
}
|
||||
|
||||
/** 서버 설계로 갈아 끼워도 **살려 두는 사용자 부속값** — 화면 조작으로만 생기는 값이다. */
|
||||
/** 다시 계산해도 **살려 두는 값** — 화면 조작으로만 생기거나 상태를 나르는 필드다. */
|
||||
const PRESERVED_KEYS = [
|
||||
"status",
|
||||
"pavement_suggested",
|
||||
"display_half_width_m",
|
||||
"inlet_structure",
|
||||
"basin_adjust",
|
||||
"revet_adjust",
|
||||
"ford_adjust",
|
||||
"box_adjust",
|
||||
"extra_wall_counts",
|
||||
"extra_spans",
|
||||
"revet_link_detached",
|
||||
"revet_follow_grade",
|
||||
] as const;
|
||||
|
||||
function preserveUserFields(
|
||||
next: NonNullable<CrossSection["design"]>,
|
||||
previous: CrossSection["design"],
|
||||
): NonNullable<CrossSection["design"]> {
|
||||
if (!previous) return next;
|
||||
return {
|
||||
...next,
|
||||
inlet_structure: previous.inlet_structure,
|
||||
basin_adjust: previous.basin_adjust,
|
||||
revet_adjust: previous.revet_adjust,
|
||||
extra_wall_counts: previous.extra_wall_counts,
|
||||
extra_spans: previous.extra_spans,
|
||||
revet_link_detached: previous.revet_link_detached,
|
||||
revet_follow_grade: previous.revet_follow_grade,
|
||||
};
|
||||
const merged = { ...next } as Record<string, unknown>;
|
||||
const source = previous as unknown as Record<string, unknown>;
|
||||
for (const key of PRESERVED_KEYS) {
|
||||
if (source[key] !== undefined) merged[key] = source[key];
|
||||
}
|
||||
return merged as unknown as NonNullable<CrossSection["design"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,8 +94,93 @@ function preserveUserFields(
|
||||
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
|
||||
*/
|
||||
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
|
||||
const local = refreshLocally(input);
|
||||
if (local !== null) return local;
|
||||
return refreshFromServer(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 안에서 전 측점을 다시 계산한다(정상 경로).
|
||||
*
|
||||
* 계획고는 저장된 자동 선형(`profile_alignment.base_pvi`)에 편집 델타를 얹어 **여기서**
|
||||
* 푼다 — B05 편집 중에는 `detail.longitudinal.design_profiles` 가 아직 옛 계획선이라
|
||||
* 그걸 쓰면 한 박자 늦은 값이 된다. 계산 재료(선형 저장분·표준단면)를 갖추지 못하면
|
||||
* `null` 을 돌려 서버 폴백으로 넘긴다.
|
||||
*/
|
||||
function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
const { projectId, detail, edits } = input;
|
||||
const stored = readAlignment(detail.longitudinal);
|
||||
if (!stored) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
|
||||
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
|
||||
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
|
||||
|
||||
const alignment = buildAlignment(toAlignmentBase(stored), {
|
||||
station_offsets: edits.station_offsets ?? {},
|
||||
curve_radii: edits.curve_radii ?? {},
|
||||
});
|
||||
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
|
||||
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
|
||||
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
|
||||
// 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다**
|
||||
// — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다.
|
||||
detail.longitudinal.design_profiles = [
|
||||
toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]),
|
||||
];
|
||||
|
||||
const rockOffsets = readRockBoundarySession(projectId, input.routeId) ?? {};
|
||||
const rockDefault = readRockBoundaryDefault(projectId);
|
||||
|
||||
const updated: number[] = [];
|
||||
for (const section of detail.cross_sections) {
|
||||
const previous = section.design;
|
||||
if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다.
|
||||
const design = previous as unknown as Record<string, unknown>;
|
||||
// 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버
|
||||
// `recompute_designs_for_alignment` 의 우선순위와 같다.
|
||||
const sessionOffset = rockOffsets[section.chainage_m.toFixed(3)];
|
||||
const storedOffset = design.rock_boundary_offset_m;
|
||||
const rockBoundaryOffsetM =
|
||||
typeof sessionOffset === "number"
|
||||
? sessionOffset
|
||||
: typeof storedOffset === "number"
|
||||
? storedOffset
|
||||
: rockDefault;
|
||||
let next;
|
||||
try {
|
||||
next = computeCrossDesign(
|
||||
section.samples ?? [],
|
||||
planElevationAt(alignment, section.chainage_m),
|
||||
{
|
||||
groundType: typeof design.ground_type === "string" ? design.ground_type : "ripping_rock",
|
||||
sectionMode: typeof design.section_mode === "string" ? design.section_mode : "left_cut",
|
||||
ditchSide: typeof design.ditch_side === "string" ? design.ditch_side : null,
|
||||
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
|
||||
ditchType: typeof design.ditch_type === "string" ? design.ditch_type : "standard",
|
||||
paved: Boolean(design.paved),
|
||||
standard,
|
||||
rockBoundaryOffsetM,
|
||||
twoStageSlope:
|
||||
design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope),
|
||||
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
|
||||
// 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다.
|
||||
surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_m : 0,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
|
||||
}
|
||||
section.design = preserveUserFields(
|
||||
next as unknown as NonNullable<CrossSection["design"]>,
|
||||
previous,
|
||||
);
|
||||
updated.push(section.chainage_m);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** 옛 데이터 폴백 — 선형 저장분이 없어 브라우저가 계획고를 풀 수 없을 때만 쓴다. */
|
||||
async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
|
||||
const { projectId, routeId, detail, edits, shouldApply } = input;
|
||||
// full_designs — 설계선 좌표까지 받아야 3D 코리도·횡단도가 편집 즉시 같은 형상이 된다.
|
||||
const response = await previewCrossDesigns(
|
||||
projectId,
|
||||
routeId,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""B06 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리.
|
||||
|
||||
⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠
|
||||
짝: `common_util/common_util_cross_design_areas.ts`
|
||||
회귀 테스트: `tmp/tests/test_b06_cross_design_mirror.py` (고칠 때 같이 돌릴 것).
|
||||
두 벌인 이유는 `B06_Section_Engine_Design.py` 머리 참조.
|
||||
|
||||
`_Engine_Design.py`가 700줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이
|
||||
넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로
|
||||
설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
"""B06 측점 표준횡단 설계 계산 엔진.
|
||||
|
||||
⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠
|
||||
짝: `common_util/common_util_cross_design.ts` (`computeCrossDesign`)
|
||||
면적 적분은 `B06_Section_Engine_Areas.py` ↔ `common_util_cross_design_areas.ts`.
|
||||
회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다:
|
||||
`tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것.
|
||||
|
||||
왜 두 벌인가: 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야
|
||||
한다**(2026-09-03 사용자 확정). 계획고가 바뀔 때마다 서버에 전 측점 횡단을 물으면
|
||||
왕복이 조작 속도를 지배했다. 이 파이썬 엔진은 자동설계 체인·[저장]·[확정]·도면
|
||||
산출의 **정본**이고, TS 미러는 조작 중 화면용이다. 값이 갈리면 화면과 납품물이
|
||||
달라지므로 새 필드·상수·판정을 더할 때 양쪽을 함께 고칠 것.
|
||||
|
||||
지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단
|
||||
설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. B06에서 사용자가
|
||||
버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서
|
||||
|
||||
@@ -516,7 +516,12 @@ async def preview_cross_designs(
|
||||
) -> CrossDesignPreviewResponse | JSONResponse:
|
||||
"""계획선 편집 델타로 계획선과 전 측점 횡단 설계를 다시 계산해 돌려준다(저장 없음).
|
||||
|
||||
B05에서 계획고를 끄는 동안 횡단 단면적·유토곡선이 함께 움직이게 하는 프리뷰 경로다.
|
||||
⚠ **평상시 조작 경로가 아니다**(2026-09-03 이후). 계획선을 만지는 동안의 재계산은
|
||||
브라우저가 직접 한다(`common_util/common_util_cross_design.ts`) — 조작 중 계산이
|
||||
서버로 나가면 왕복이 조작 속도를 지배하기 때문이다(사용자 확정). 이 엔드포인트는
|
||||
선형 저장분이 없어 브라우저가 계획고를 풀 수 없는 **옛 데이터 폴백**으로만 남는다
|
||||
(`B06_Section_Cross_Refresh.refreshCrossDesigns`).
|
||||
|
||||
영속화는 각 페이지의 임시저장·확정이 맡는다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
|
||||
@@ -31,13 +31,17 @@ import {
|
||||
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View";
|
||||
import { staleDesignChainages } from "./B06_Section_UI_Section_Common";
|
||||
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
|
||||
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
applyPipeOptionsToCache,
|
||||
type PipeOptionsContext,
|
||||
} from "./B06_Section_UI_Page_Pipe_Options";
|
||||
import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
createStandardPanel,
|
||||
rememberRockBoundaryDefault,
|
||||
type StandardPanelController,
|
||||
} from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
createB06StructuresPanel,
|
||||
isWallSelecting,
|
||||
@@ -285,9 +289,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
*/
|
||||
async function reconcileStaleDesigns(): Promise<void> {
|
||||
if (!sectionDetail || !projectId || currentRouteId === null) return;
|
||||
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 staleDesignChainages — 계획고 어긋남 +
|
||||
const draft = readAlignmentDraft(currentRouteId);
|
||||
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
|
||||
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
|
||||
if (!staleDesignChainages(sectionDetail).length) return;
|
||||
//
|
||||
// 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은
|
||||
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
|
||||
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
|
||||
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
|
||||
if (!draft && !hasStaleDesigns(sectionDetail)) return;
|
||||
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
|
||||
// (CLAUDE.md 5장).
|
||||
try {
|
||||
@@ -299,7 +309,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const edits = readAlignmentDraft(currentRouteId) ?? {
|
||||
const edits = draft ?? {
|
||||
station_offsets: alignment?.edits?.station_offsets ?? {},
|
||||
curve_radii: alignment?.edits?.curve_radii ?? {},
|
||||
};
|
||||
@@ -588,6 +598,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
|
||||
// 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시).
|
||||
standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll);
|
||||
// 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다.
|
||||
rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m);
|
||||
standardPanelSlot.append(standardPanel.root);
|
||||
|
||||
if (context.route_id === null) {
|
||||
|
||||
@@ -78,11 +78,7 @@ export function validElevation(
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다.
|
||||
* 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다.
|
||||
*/
|
||||
/**
|
||||
* 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**을 찾는다(B05·B06 공용 규칙).
|
||||
* 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**이 하나라도 있는가(B05·B06 공용 규칙).
|
||||
*
|
||||
* 횡단 설계는 계산 당시 계획고(`design.design_elevation_m`)를 기준으로 설계선 좌표를
|
||||
* 굳혀 둔다. 계획선이 그 뒤에 바뀌면(사용자 편집·확정, 배수 최소고 같은 백엔드 규칙
|
||||
@@ -90,58 +86,102 @@ export function validElevation(
|
||||
* — 두 기준이 어긋나 횡단도·3D가 종단을 안 따라오는 것처럼 보인다(2026-08-23 실측:
|
||||
* 한 노선에서 최대 2.21m 어긋남). 재계산 대상을 한 규칙으로 판정해 두 화면이 같은
|
||||
* 시점에 같은 조치를 하게 한다.
|
||||
*
|
||||
* 그리기마다 불리므로 **첫 건에서 멈춘다** — 전 측점을 훑어 목록을 만들던 옛 판은
|
||||
* 프레임당 2.33ms 를 먹어 계획선 편집을 굼뜨게 했다(2026-09-03 실측).
|
||||
*/
|
||||
export function staleDesignChainages(
|
||||
export function hasStaleDesigns(
|
||||
detail: {
|
||||
longitudinal: { design_profiles?: DesignProfile[] };
|
||||
cross_sections: Array<{
|
||||
chainage_m: number;
|
||||
design?: {
|
||||
design_elevation_m: number;
|
||||
geometry_preset?: string;
|
||||
two_stage_slope?: boolean;
|
||||
} | null;
|
||||
}>;
|
||||
cross_sections: StaleCandidate[];
|
||||
},
|
||||
toleranceM = 1e-3,
|
||||
): number[] {
|
||||
): boolean {
|
||||
const profiles = detail.longitudinal.design_profiles;
|
||||
return detail.cross_sections
|
||||
.filter((section) => {
|
||||
const design = section.design;
|
||||
if (!design) return false;
|
||||
// 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다.
|
||||
// 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의
|
||||
// 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다.
|
||||
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
|
||||
if (!profiles?.length) return false;
|
||||
const planned = designElevationAt(profiles, section.chainage_m);
|
||||
return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM;
|
||||
})
|
||||
.map((section) => section.chainage_m);
|
||||
return detail.cross_sections.some((section) => isStaleSection(section, profiles, toleranceM));
|
||||
}
|
||||
|
||||
interface StaleCandidate {
|
||||
chainage_m: number;
|
||||
design?: {
|
||||
design_elevation_m: number;
|
||||
geometry_preset?: string;
|
||||
two_stage_slope?: boolean;
|
||||
surface_drop_m?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function isStaleSection(
|
||||
section: StaleCandidate,
|
||||
profiles: DesignProfile[] | undefined,
|
||||
toleranceM: number,
|
||||
): boolean {
|
||||
const design = section.design;
|
||||
if (!design) return false;
|
||||
// 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다.
|
||||
// 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의
|
||||
// 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다.
|
||||
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
|
||||
if (!profiles?.length) return false;
|
||||
const planned = designElevationAt(profiles, section.chainage_m);
|
||||
if (planned === undefined) return false;
|
||||
// 세월교가 앉은 측점의 노면은 월류 높이만큼 **일부러** 내려 앉는다 — 그 차이를 빼지
|
||||
// 않으면 계획선을 안 건드려도 늘 어긋난 것으로 나온다(2026-09-03 사용자 보고).
|
||||
const designed = design.design_elevation_m + (design.surface_drop_m ?? 0);
|
||||
return Math.abs(planned - designed) > toleranceM;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선 샘플에서 유효분만 걸러 둔 사본 — **샘플 배열 하나당 한 번만** 만든다.
|
||||
*
|
||||
* 이 함수는 측점마다 불린다(측점 128 × 샘플 2,159 규모). 호출마다 `filter()` 로 새
|
||||
* 배열을 만들면 그리기 한 프레임이 통째로 그 비용에 먹힌다(2026-09-03 실측: 프레임당
|
||||
* 2.33ms, 계획선 편집이 굼떠지는 원인). 원본 배열이 바뀌면 새 객체가 오므로 키로 쓴다.
|
||||
*/
|
||||
const validSamplesCache = new WeakMap<object, Array<{ chainage_m: number; elevation_m: number }>>();
|
||||
|
||||
function validPlanSamples(
|
||||
designProfiles: DesignProfile[] | undefined,
|
||||
): Array<{ chainage_m: number; elevation_m: number }> | undefined {
|
||||
const samples = designProfiles?.[0]?.samples;
|
||||
if (!samples?.length) return undefined;
|
||||
const cached = validSamplesCache.get(samples);
|
||||
if (cached) return cached.length ? cached : undefined;
|
||||
const filtered = samples.filter((sample) => Number.isFinite(sample.elevation_m)) as Array<{
|
||||
chainage_m: number;
|
||||
elevation_m: number;
|
||||
}>;
|
||||
validSamplesCache.set(samples, filtered);
|
||||
return filtered.length ? filtered : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다.
|
||||
* 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다.
|
||||
*/
|
||||
export function designElevationAt(
|
||||
designProfiles: DesignProfile[] | undefined,
|
||||
chainageM: number,
|
||||
): number | undefined {
|
||||
const samples = designProfiles?.[0]?.samples?.filter((sample) =>
|
||||
Number.isFinite(sample.elevation_m),
|
||||
);
|
||||
if (!samples?.length) return undefined;
|
||||
const samples = validPlanSamples(designProfiles);
|
||||
if (!samples) return undefined;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m;
|
||||
const last = samples[samples.length - 1];
|
||||
if (chainageM >= last.chainage_m) return last.elevation_m;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const previous = samples[index - 1];
|
||||
const current = samples[index];
|
||||
if (chainageM > current.chainage_m) continue;
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
if (span <= 0) return current.elevation_m;
|
||||
const ratio = (chainageM - previous.chainage_m) / span;
|
||||
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
||||
// 누가거리 순 배열이라 이진탐색으로 구간을 고른다(선형 훑기와 결과는 같다).
|
||||
let low = 1;
|
||||
let high = samples.length - 1;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (samples[mid].chainage_m < chainageM) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return last.elevation_m;
|
||||
const previous = samples[low - 1];
|
||||
const current = samples[low];
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
if (span <= 0) return current.elevation_m;
|
||||
const ratio = (chainageM - previous.chainage_m) / span;
|
||||
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 범례 → 요약줄까지의 한 덩어리는 여기서 만든다. 계산·판정은 전부 공용 모듈
|
||||
* (`common_util_mass_haul*`)이 하고 여기서는 **어디에 무엇을 붙일지**만 정한다.
|
||||
*
|
||||
* 낡음 판정(`staleDesignChainages`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금
|
||||
* 낡음 판정(`hasStaleDesigns`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금
|
||||
* 계획선과 어긋나면 곡선을 그리지 않고 안내만 띄운다(2026-09-03 사용자 확정:
|
||||
* 「새 값만 보여주기」). 옛 계획고로 만든 면적을 잠깐 보여 주고 정본으로 갈아 끼우면
|
||||
* 사용자가 옛 그림을 본다.
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
L,
|
||||
longitudinalMaxChainage,
|
||||
LONG_PAD,
|
||||
staleDesignChainages,
|
||||
hasStaleDesigns,
|
||||
} from "./B06_Section_UI_Section_Common";
|
||||
|
||||
/** 유토곡선 Y축 눈금 — 가로 스크롤 고정 오버레이가 그대로 받는다. */
|
||||
@@ -76,7 +76,7 @@ export interface MassHaulPanelResult {
|
||||
*/
|
||||
export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult {
|
||||
const { detail, visibleSeries } = input;
|
||||
const pendingRecalc = staleDesignChainages(detail).length > 0;
|
||||
const pendingRecalc = hasStaleDesigns(detail);
|
||||
const series: MassHaulSeries[] =
|
||||
input.conversion && !pendingRecalc
|
||||
? computeMassHaulSeries(
|
||||
|
||||
@@ -33,11 +33,76 @@ const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [
|
||||
];
|
||||
|
||||
const SESSION_PREFIX = "b06:std-cross:";
|
||||
/** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */
|
||||
const DEFAULTS_PREFIX = "b06:std-cross-default:";
|
||||
|
||||
function sessionKey(projectId: string): string {
|
||||
return `${SESSION_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
function defaultsKey(projectId: string): string {
|
||||
return `${DEFAULTS_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 내려 준 표준횡단 **config 기본값**을 세션에 둔다(`sections/context` 응답).
|
||||
*
|
||||
* 횡단 계산이 브라우저에서 돌므로 config 수치가 프론트에도 있어야 하는데, 상수를
|
||||
* 복제하면 정본이 둘이 된다. 그래서 복제 대신 **서버가 준 값을 그대로 기억**한다.
|
||||
* 패널을 열지 않는 B05도 이 값으로 계산해야 두 화면 결과가 같다(2026-09-03 로컬 전환).
|
||||
*/
|
||||
export function rememberStandardDefaults(projectId: string, defaults: StandardCrossSection): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(defaultsKey(projectId), JSON.stringify(defaults));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 계산은 편집값·저장분으로 이어 간다. */
|
||||
}
|
||||
}
|
||||
|
||||
/** 기억해 둔 config 기본값. 아직 컨텍스트를 못 받았으면 null. */
|
||||
export function readStandardDefaults(projectId: string): StandardCrossSection | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(defaultsKey(projectId));
|
||||
return raw ? (JSON.parse(raw) as StandardCrossSection) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ROCK_DEFAULT_PREFIX = "b06:rock-boundary-default:";
|
||||
|
||||
/**
|
||||
* 암반 경계선 기본 오프셋(config `STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M`)을 기억한다.
|
||||
* 저장분에 경계 오프셋이 없는 옛 암 측점을 서버와 **같은 기본값**으로 다시 계산하려면
|
||||
* 브라우저에도 이 값이 있어야 한다 — 상수 복제 대신 컨텍스트 응답을 기억하는 방식이다.
|
||||
*/
|
||||
export function rememberRockBoundaryDefault(projectId: string, offsetM: number): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(`${ROCK_DEFAULT_PREFIX}${projectId}`, String(offsetM));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시. */
|
||||
}
|
||||
}
|
||||
|
||||
/** 기억해 둔 암반 경계 기본 오프셋(m). 없으면 null. */
|
||||
export function readRockBoundaryDefault(projectId: string): number | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(`${ROCK_DEFAULT_PREFIX}${projectId}`);
|
||||
const parsed = raw === null ? Number.NaN : Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단 계산에 넣을 표준단면 한 벌 — **세션 편집값이 있으면 그쪽, 없으면 config 기본값**.
|
||||
* 서버 `_resolve_group(preset, standard)`의 "요청값 → config" 우선순위와 같은 뜻이다.
|
||||
*/
|
||||
export function effectiveStandardCross(projectId: string): StandardCrossSection | null {
|
||||
return readStandardCrossSession(projectId) ?? readStandardDefaults(projectId);
|
||||
}
|
||||
|
||||
/** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */
|
||||
export function clearStandardCrossSession(projectId: string): void {
|
||||
try {
|
||||
@@ -174,6 +239,8 @@ export function createStandardPanel(
|
||||
defaults: StandardCrossSection,
|
||||
onApplyAll?: () => void | Promise<void>,
|
||||
): StandardPanelController {
|
||||
// 브라우저 횡단 계산이 패널 없이도 config 기본값을 쓸 수 있게 먼저 기억해 둔다.
|
||||
rememberStandardDefaults(projectId, defaults);
|
||||
// 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다.
|
||||
const sessionValue = readStandardCrossSession(projectId);
|
||||
const hadSession = sessionValue !== null;
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
/* =============================================================================
|
||||
* common_util_cross_design.ts
|
||||
* B06 측점 표준횡단 설계 계산 — **브라우저 판**.
|
||||
*
|
||||
* ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠
|
||||
* 짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`)
|
||||
* 면적 적분은 `common_util_cross_design_areas.ts` ↔ `B06_Section_Engine_Areas.py`.
|
||||
* 회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다:
|
||||
* `tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것.
|
||||
*
|
||||
* ── 왜 같은 계산이 두 벌인가 (2026-09-03 사용자 확정) ────────────────
|
||||
* 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야 한다**. 조작은 세션
|
||||
* 캐시에 쌓이고 화면은 즉시 따라오며, 서버는 [저장]·[확정]에서만 부른다. 계획고가 바뀔
|
||||
* 때마다 서버에 전 측점 횡단을 물으면 왕복이 조작 속도를 지배한다(2026-09-03 실측 보고).
|
||||
* 계획선 선형(`B05_Profile_Engine_Grade_Alignment.py` ↔ `B05_Profile_UI_Profile_Alignment.ts`)
|
||||
* 이 이미 같은 이유로 1:1 미러다.
|
||||
*
|
||||
* ── 갈라지지 않게 하는 규칙 ──────────────────────────────────────────
|
||||
* 1. **config 상수를 여기에 복제하지 않는다.** 표준단면 수치는 서버가 config 에서 내려
|
||||
* 주는 `sections/context.standard_cross_section` 을 입력으로 받는다. 아래 상수는
|
||||
* config 의 *열거값*(지반유형→프리셋 등)뿐이며 그마저 짝 파일과 나란히 둔다.
|
||||
* 2. 반올림·경계 판정 상수까지 파이썬과 같은 값을 쓴다.
|
||||
* 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
|
||||
|
||||
/** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */
|
||||
const GROUND_TYPE_PRESET: Record<string, string> = {
|
||||
soil: "soil",
|
||||
ripping_rock: "rock",
|
||||
blasting_rock: "rock",
|
||||
};
|
||||
/** 단면유형. 짝: config `SECTION_MODES`. 좌=양(+)offset, 우=음(-)offset. */
|
||||
const SECTION_MODES = ["left_cut", "right_cut", "both_cut", "both_fill"];
|
||||
/** 짝: config `SECTION_DITCH_SIDES` / `SECTION_DITCH_TYPES`. */
|
||||
const DITCH_SIDES = ["left", "right"];
|
||||
const DITCH_TYPES = ["standard", "l_type"];
|
||||
|
||||
/** 사면이 원지반과 만났다고 볼 높이차(m). 짝: `_SLOPE_CLOSE_TOLERANCE_M`. */
|
||||
const SLOPE_CLOSE_TOLERANCE_M = 0.01;
|
||||
/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */
|
||||
const MARCH_STEP_M = 0.05;
|
||||
const KNEE_MAX_M = 200;
|
||||
const CROSS_MAX_M = 500;
|
||||
|
||||
export interface CrossGroundSample {
|
||||
offset_m?: number | null;
|
||||
elevation_m?: number | null;
|
||||
valid?: boolean;
|
||||
}
|
||||
|
||||
/** `sections/context.standard_cross_section` 한 그룹의 모양(서버가 config 에서 내려 준다). */
|
||||
export interface StandardGroupSpec {
|
||||
road_width_m?: number;
|
||||
shoulder_left_m?: number;
|
||||
shoulder_right_m?: number;
|
||||
ditch?: { top_width_m?: number; bottom_width_m?: number; depth_m?: number };
|
||||
ditch_l_type?: { width_m?: number; depth_m?: number };
|
||||
cross_slope_pct?: { min?: number; max?: number };
|
||||
fill_slope_ratio?: number;
|
||||
cut_slope_ratio?: number;
|
||||
pavement_thickness_m?: number;
|
||||
}
|
||||
|
||||
/** 프리셋 키(soil/rock/paved) → 그룹 값. 세션 편집값도 같은 모양이다. */
|
||||
export type StandardCrossSectionSpec = Record<string, StandardGroupSpec | undefined>;
|
||||
|
||||
export interface CrossDesignOptions {
|
||||
groundType: string;
|
||||
sectionMode: string;
|
||||
ditchSide?: string | null;
|
||||
ditchType?: string;
|
||||
paved?: boolean;
|
||||
/** 표준단면 수치(필수) — config 기본값 또는 그 위에 얹은 세션 편집값. */
|
||||
standard: StandardCrossSectionSpec;
|
||||
rockBoundaryOffsetM?: number | null;
|
||||
twoStageSlope?: boolean;
|
||||
ditchEnabled?: boolean | null;
|
||||
/** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */
|
||||
surfaceDropM?: number;
|
||||
}
|
||||
|
||||
export interface CrossDesignEdge {
|
||||
offset_m: number;
|
||||
elevation_m: number;
|
||||
}
|
||||
|
||||
export interface CrossDesignResult {
|
||||
ground_type: string;
|
||||
geometry_preset: string;
|
||||
section_mode: string;
|
||||
ditch_side: string;
|
||||
ditch_type: string | null;
|
||||
cut_slope_ratio: number;
|
||||
soil_cut_slope_ratio: number;
|
||||
two_stage_slope: boolean;
|
||||
fill_slope_ratio: number;
|
||||
roadbed_width_m: number;
|
||||
carriageway_width_m: number;
|
||||
cross_slope_pct: number;
|
||||
ditch: Record<string, unknown>;
|
||||
ditch_enabled: boolean;
|
||||
paved: boolean;
|
||||
road_edges: { left: CrossDesignEdge; right: CrossDesignEdge };
|
||||
carriageway_edges: { left: CrossDesignEdge; right: CrossDesignEdge };
|
||||
design_elevation_m: number;
|
||||
cut_area_m2: number;
|
||||
cut_soil_area_m2: number;
|
||||
cut_rock_area_m2: number;
|
||||
cut_rock_kind: string | null;
|
||||
fill_area_m2: number;
|
||||
slope_unclosed: boolean;
|
||||
fill_ground_slope: number | null;
|
||||
ditch_area_m2: number;
|
||||
design_line: CrossDesignEdge[];
|
||||
surface_drop_m?: number;
|
||||
pavement_thickness_m?: number;
|
||||
rock_boundary_offset_m?: number;
|
||||
}
|
||||
|
||||
/** 짝: 파이썬 `round(value, 4)`. */
|
||||
function round4(value: number): number {
|
||||
return Math.round(value * 1e4) / 1e4;
|
||||
}
|
||||
|
||||
/** 짝: `_as_float` — 손상값·음수면 fallback. */
|
||||
function asFloat(value: unknown, fallback: number): number {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
interface ResolvedGroup {
|
||||
road_width_m: number;
|
||||
shoulder_left_m: number;
|
||||
shoulder_right_m: number;
|
||||
ditch_top_width_m: number;
|
||||
ditch_bottom_width_m: number;
|
||||
ditch_depth_m: number;
|
||||
l_ditch_width_m: number;
|
||||
l_ditch_depth_m: number;
|
||||
cross_slope_pct: number;
|
||||
fill_slope_ratio: number;
|
||||
cut_slope_ratio: number;
|
||||
pavement_thickness_m: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 짝: `_resolve_group`. 파이썬은 config 위에 패널 편집값을 덮지만, 여기서는 이미 그렇게
|
||||
* 합쳐진 한 벌(`standard`)을 받는다 — config 수치를 프론트에 복제하지 않기 위함이다.
|
||||
*/
|
||||
function resolveGroup(presetKey: string, standard: StandardCrossSectionSpec): ResolvedGroup {
|
||||
const group = standard[presetKey];
|
||||
if (!group) {
|
||||
throw new Error(`표준횡단 설정에 '${presetKey}' 그룹이 없어 횡단 설계를 계산할 수 없습니다.`);
|
||||
}
|
||||
const ditch = group.ditch ?? {};
|
||||
const lDitch = group.ditch_l_type ?? {};
|
||||
const slope = group.cross_slope_pct ?? {};
|
||||
return {
|
||||
road_width_m: asFloat(group.road_width_m, 0),
|
||||
shoulder_left_m: asFloat(group.shoulder_left_m, 0),
|
||||
shoulder_right_m: asFloat(group.shoulder_right_m, 0),
|
||||
ditch_top_width_m: asFloat(ditch.top_width_m, 0),
|
||||
ditch_bottom_width_m: asFloat(ditch.bottom_width_m, 0),
|
||||
ditch_depth_m: asFloat(ditch.depth_m, 0),
|
||||
// 짝 파일의 `base.get("width_m", 0.5)`와 같은 최후 기본값.
|
||||
l_ditch_width_m: asFloat(lDitch.width_m, 0.5),
|
||||
l_ditch_depth_m: asFloat(lDitch.depth_m, 0.1),
|
||||
// 횡단경사는 범위(min~max) 중 하한을 기본 채택한다(도면 표기 앞값).
|
||||
cross_slope_pct: asFloat(slope.min, 0),
|
||||
fill_slope_ratio: asFloat(group.fill_slope_ratio, 0),
|
||||
cut_slope_ratio: asFloat(group.cut_slope_ratio, 0),
|
||||
pavement_thickness_m: asFloat(group.pavement_thickness_m, 0.2),
|
||||
};
|
||||
}
|
||||
|
||||
/** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */
|
||||
function sideRole(sectionMode: string): [string, string] {
|
||||
if (sectionMode === "left_cut") return ["cut", "fill"];
|
||||
if (sectionMode === "right_cut") return ["fill", "cut"];
|
||||
if (sectionMode === "both_cut") return ["cut", "cut"];
|
||||
if (sectionMode === "both_fill") return ["fill", "fill"];
|
||||
throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`);
|
||||
}
|
||||
|
||||
/** 짝: `_resolve_ditch_side`. */
|
||||
function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefined): string {
|
||||
if (sectionMode === "left_cut") return "left";
|
||||
if (sectionMode === "right_cut") return "right";
|
||||
if (ditchSide && DITCH_SIDES.includes(ditchSide)) return ditchSide;
|
||||
return "left";
|
||||
}
|
||||
|
||||
/**
|
||||
* 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프).
|
||||
*
|
||||
* 짝 파일은 앞에서부터 훑지만 여기서는 이진탐색으로 같은 구간을 고른다 — 사면·지반
|
||||
* 교차 행진이 이 함수를 수만 번 부르는데 브라우저에서는 그 비용이 조작 속도에 바로
|
||||
* 드러나기 때문이다. **결과는 완전히 같다**(같은 구간, 같은 선형보간).
|
||||
*/
|
||||
function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) => number {
|
||||
const last = valid.length - 1;
|
||||
return (offsetM: number): number => {
|
||||
if (offsetM <= valid[0][0]) return valid[0][1];
|
||||
if (offsetM >= valid[last][0]) return valid[last][1];
|
||||
let low = 1;
|
||||
let high = last;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (valid[mid][0] < offsetM) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
const [x1, z1] = valid[low];
|
||||
const [x0, z0] = valid[low - 1];
|
||||
const span = x1 - x0;
|
||||
if (span <= 0) return z1;
|
||||
return z0 + (z1 - z0) * ((offsetM - x0) / span);
|
||||
};
|
||||
}
|
||||
|
||||
/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */
|
||||
class SectionGeometry {
|
||||
halfRoad: number;
|
||||
leftExtent: number;
|
||||
rightExtent: number;
|
||||
zCenter: number;
|
||||
cutRatio: number;
|
||||
fillRatio: number;
|
||||
leftRole: string;
|
||||
rightRole: string;
|
||||
ditchSide: string;
|
||||
soilCutRatio: number;
|
||||
twoStage: boolean;
|
||||
ditchType: string;
|
||||
slopePerOffset: number;
|
||||
hasDitch: boolean;
|
||||
ditchPoints: Array<[number, number]> = [];
|
||||
private groundAt: ((offsetM: number) => number) | null;
|
||||
private rockOffset: number;
|
||||
private rockKnee = new Map<string, [number, number] | null>();
|
||||
private cutCross = new Map<string, number | null>();
|
||||
private fillCross = new Map<string, number | null>();
|
||||
|
||||
constructor(params: {
|
||||
designElevationM: number;
|
||||
group: ResolvedGroup;
|
||||
sectionMode: string;
|
||||
ditchSide: string;
|
||||
ditchType: string;
|
||||
crossSlopePct: number;
|
||||
groundAt: ((offsetM: number) => number) | null;
|
||||
soilCutRatio: number | null;
|
||||
rockBoundaryOffsetM: number | null;
|
||||
twoStageSlope: boolean;
|
||||
ditchEnabled: boolean | null;
|
||||
}) {
|
||||
const { group } = params;
|
||||
const halfRoad = group.road_width_m / 2;
|
||||
this.halfRoad = halfRoad;
|
||||
this.leftExtent = halfRoad + group.shoulder_left_m;
|
||||
this.rightExtent = halfRoad + group.shoulder_right_m;
|
||||
this.zCenter = params.designElevationM;
|
||||
this.cutRatio = Math.max(group.cut_slope_ratio, 1e-6);
|
||||
this.fillRatio = Math.max(group.fill_slope_ratio, 1e-6);
|
||||
[this.leftRole, this.rightRole] = sideRole(params.sectionMode);
|
||||
this.ditchSide = params.ditchSide;
|
||||
this.soilCutRatio = Math.max(params.soilCutRatio ?? group.cut_slope_ratio, 1e-6);
|
||||
this.twoStage = Boolean(
|
||||
params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null,
|
||||
);
|
||||
this.groundAt = params.groundAt;
|
||||
this.rockOffset = params.rockBoundaryOffsetM ?? 0;
|
||||
this.ditchType = params.ditchType;
|
||||
// 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약).
|
||||
const slope = params.crossSlopePct / 100;
|
||||
this.slopePerOffset = params.ditchSide === "left" ? -slope : slope;
|
||||
|
||||
// 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토.
|
||||
const groundAt = params.groundAt;
|
||||
if (groundAt !== null) {
|
||||
this.leftRole =
|
||||
groundAt(this.leftExtent) > this.roadZ(this.leftExtent) + 1e-3 ? "cut" : "fill";
|
||||
this.rightRole =
|
||||
groundAt(-this.rightExtent) > this.roadZ(-this.rightExtent) + 1e-3 ? "cut" : "fill";
|
||||
}
|
||||
|
||||
// 측구 생성 여부(D-1).
|
||||
if (params.sectionMode === "both_fill") {
|
||||
this.hasDitch = false;
|
||||
} else if (params.ditchEnabled !== null && params.ditchEnabled !== undefined) {
|
||||
this.hasDitch = params.ditchEnabled;
|
||||
} else if (groundAt !== null) {
|
||||
const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
|
||||
this.hasDitch = groundAt(ditchEdge) > this.roadZ(ditchEdge) + 1e-3;
|
||||
} else {
|
||||
this.hasDitch = true;
|
||||
}
|
||||
|
||||
// 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
|
||||
const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
|
||||
const outward = params.ditchSide === "left" ? 1 : -1;
|
||||
const edgeZ = this.roadZ(edgeOffset);
|
||||
if (this.hasDitch) {
|
||||
if (params.ditchType === "l_type") {
|
||||
// L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽.
|
||||
const width = group.l_ditch_width_m;
|
||||
const depth = group.l_ditch_depth_m;
|
||||
this.ditchPoints = [
|
||||
[edgeOffset, edgeZ],
|
||||
[edgeOffset + outward * width, edgeZ - depth],
|
||||
[edgeOffset + outward * width, edgeZ],
|
||||
];
|
||||
} else {
|
||||
// 일반: 상단폭/저폭/깊이 사다리꼴.
|
||||
const top = group.ditch_top_width_m;
|
||||
const bottom = Math.min(group.ditch_bottom_width_m, top);
|
||||
const depth = group.ditch_depth_m;
|
||||
const inset = (top - bottom) / 2;
|
||||
this.ditchPoints = [
|
||||
[edgeOffset, edgeZ],
|
||||
[edgeOffset + outward * inset, edgeZ - depth],
|
||||
[edgeOffset + outward * (inset + bottom), edgeZ - depth],
|
||||
[edgeOffset + outward * top, edgeZ],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */
|
||||
roadZ(offsetM: number): number {
|
||||
return this.zCenter + this.slopePerOffset * offsetM;
|
||||
}
|
||||
|
||||
/** 짝: `_slope_start`. 사면 시작점 [오프셋 절대값 거리, 표고]. */
|
||||
private slopeStart(side: string): [number, number] {
|
||||
const edgeOffset = side === "left" ? this.leftExtent : this.rightExtent;
|
||||
const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent);
|
||||
if (side === this.ditchSide && this.ditchPoints.length) {
|
||||
const outer = this.ditchPoints[this.ditchPoints.length - 1];
|
||||
return [Math.abs(outer[0]), outer[1]];
|
||||
}
|
||||
return [edgeOffset, edgeZ];
|
||||
}
|
||||
|
||||
/** 짝: `_rock_boundary_z`. 암반 경계선 표고 = 지반선 + 오프셋(음수=하향). */
|
||||
private rockBoundaryZ(side: string, dist: number): number {
|
||||
const signed = side === "left" ? dist : -dist;
|
||||
return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset;
|
||||
}
|
||||
|
||||
/** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */
|
||||
knee(side: string): [number, number] | null {
|
||||
if (!this.twoStage) return null;
|
||||
const cached = this.rockKnee.get(side);
|
||||
if (cached !== undefined) return cached;
|
||||
const [startDist, startZ] = this.slopeStart(side);
|
||||
let diffPrev = startZ - this.rockBoundaryZ(side, startDist);
|
||||
let result: [number, number] | null;
|
||||
if (diffPrev >= 0) {
|
||||
result = [startDist, startZ]; // 시작부터 토사(경계 위)
|
||||
} else {
|
||||
result = null;
|
||||
let distPrev = startDist;
|
||||
let dist = startDist + MARCH_STEP_M;
|
||||
while (dist <= startDist + KNEE_MAX_M) {
|
||||
const zRock = startZ + (dist - startDist) / this.cutRatio;
|
||||
const diff = zRock - this.rockBoundaryZ(side, dist);
|
||||
if (diff >= 0) {
|
||||
const span = diff - diffPrev;
|
||||
const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0;
|
||||
const kneeDist = distPrev + (dist - distPrev) * ratio;
|
||||
const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio;
|
||||
result = [kneeDist, kneeZ];
|
||||
break;
|
||||
}
|
||||
distPrev = dist;
|
||||
diffPrev = diff;
|
||||
dist += MARCH_STEP_M;
|
||||
}
|
||||
}
|
||||
this.rockKnee.set(side, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */
|
||||
private cutSlopeZ(side: string, dist: number): number {
|
||||
const [startDist, startZ] = this.slopeStart(side);
|
||||
const knee = this.twoStage ? this.knee(side) : null;
|
||||
if (knee !== null) {
|
||||
const [kneeDist, kneeZ] = knee;
|
||||
if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio;
|
||||
return kneeZ + (dist - kneeDist) / this.soilCutRatio;
|
||||
}
|
||||
return startZ + (dist - startDist) / this.cutRatio;
|
||||
}
|
||||
|
||||
/** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */
|
||||
cutCrossDist(side: string): number | null {
|
||||
const cached = this.cutCross.get(side);
|
||||
if (cached !== undefined) return cached;
|
||||
let result: number | null = null;
|
||||
if (this.groundAt !== null) {
|
||||
const [startDist] = this.slopeStart(side);
|
||||
let dist = startDist;
|
||||
const maxDist = startDist + CROSS_MAX_M;
|
||||
while (dist <= maxDist) {
|
||||
const signed = side === "left" ? dist : -dist;
|
||||
if (this.cutSlopeZ(side, dist) - this.groundAt(signed) >= 0) {
|
||||
result = dist;
|
||||
break;
|
||||
}
|
||||
dist += MARCH_STEP_M;
|
||||
}
|
||||
}
|
||||
this.cutCross.set(side, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 짝: `fill_cross_dist`. 성토 사면이 지반선과 처음 만나는 거리. */
|
||||
fillCrossDist(side: string): number | null {
|
||||
const cached = this.fillCross.get(side);
|
||||
if (cached !== undefined) return cached;
|
||||
let result: number | null = null;
|
||||
if (this.groundAt !== null) {
|
||||
const [startDist, startZ] = this.slopeStart(side);
|
||||
let dist = startDist;
|
||||
const maxDist = startDist + CROSS_MAX_M;
|
||||
while (dist <= maxDist) {
|
||||
const signed = side === "left" ? dist : -dist;
|
||||
const fillLine = startZ - (dist - startDist) / this.fillRatio;
|
||||
if (fillLine - this.groundAt(signed) <= 0) {
|
||||
result = dist;
|
||||
break;
|
||||
}
|
||||
dist += MARCH_STEP_M;
|
||||
}
|
||||
}
|
||||
this.fillCross.set(side, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 짝: `fill_ground_slope`. 성토측 자연 지반 평균 경사(rise/run). */
|
||||
fillGroundSlope(): number | null {
|
||||
if (this.groundAt === null) return null;
|
||||
const slopes: number[] = [];
|
||||
for (const side of ["left", "right"]) {
|
||||
const role = side === "left" ? this.leftRole : this.rightRole;
|
||||
if (role !== "fill") continue;
|
||||
const [startDist] = this.slopeStart(side);
|
||||
const endDist = this.fillCrossDist(side) ?? startDist + 10;
|
||||
const run = endDist - startDist;
|
||||
if (run <= 1e-6) continue;
|
||||
const sign = side === "left" ? 1 : -1;
|
||||
const rise = Math.abs(this.groundAt(sign * endDist) - this.groundAt(sign * startDist));
|
||||
slopes.push(rise / run);
|
||||
}
|
||||
return slopes.length ? Math.min(...slopes) : null;
|
||||
}
|
||||
|
||||
/** 짝: `design_z`. offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종). */
|
||||
designZ(offsetM: number, groundM: number): number {
|
||||
const side = offsetM >= 0 ? "left" : "right";
|
||||
const extent = side === "left" ? this.leftExtent : this.rightExtent;
|
||||
if (Math.abs(offsetM) <= extent + 1e-9) return this.roadZ(offsetM);
|
||||
// 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착).
|
||||
if (side === this.ditchSide && this.ditchPoints.length) {
|
||||
const inner = Math.abs(this.ditchPoints[0][0]);
|
||||
const outer = Math.abs(this.ditchPoints[this.ditchPoints.length - 1][0]);
|
||||
if (Math.abs(offsetM) >= inner - 1e-9 && Math.abs(offsetM) <= outer + 1e-9) {
|
||||
const points = this.ditchPoints;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const x0 = Math.abs(points[index - 1][0]);
|
||||
const z0 = points[index - 1][1];
|
||||
const x1 = Math.abs(points[index][0]);
|
||||
const z1 = points[index][1];
|
||||
if (Math.abs(offsetM) > x1 + 1e-9) continue;
|
||||
const span = x1 - x0;
|
||||
if (span <= 1e-9) return z1;
|
||||
return z0 + (z1 - z0) * ((Math.abs(offsetM) - x0) / span);
|
||||
}
|
||||
return points[points.length - 1][1];
|
||||
}
|
||||
}
|
||||
const role = side === "left" ? this.leftRole : this.rightRole;
|
||||
const [startDist, startZ] = this.slopeStart(side);
|
||||
const dist = Math.abs(offsetM);
|
||||
const run = dist - startDist;
|
||||
if (role === "cut") {
|
||||
const cross = this.cutCrossDist(side);
|
||||
if (cross !== null && dist >= cross) return groundM;
|
||||
return Math.min(this.cutSlopeZ(side, dist), groundM);
|
||||
}
|
||||
const cross = this.fillCrossDist(side);
|
||||
if (cross !== null && dist >= cross) return groundM;
|
||||
return Math.max(startZ - run / this.fillRatio, groundM);
|
||||
}
|
||||
|
||||
/** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */
|
||||
breakpoints(): number[] {
|
||||
const points = [0, this.leftExtent, -this.rightExtent];
|
||||
for (const [offset] of this.ditchPoints) points.push(offset);
|
||||
if (this.twoStage) {
|
||||
for (const side of ["left", "right"]) {
|
||||
const role = side === "left" ? this.leftRole : this.rightRole;
|
||||
const knee = role === "cut" ? this.knee(side) : null;
|
||||
if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]);
|
||||
}
|
||||
}
|
||||
for (const side of ["left", "right"]) {
|
||||
const role = side === "left" ? this.leftRole : this.rightRole;
|
||||
const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side);
|
||||
if (cross !== null) points.push(side === "left" ? cross : -cross);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 짝: `compute_cross_design`. 측점 하나의 표준횡단 설계선과 절·성토 단면적을 낸다.
|
||||
*
|
||||
* `samples` 는 지반선 원시 샘플, `designElevationM` 은 중심선 계획고(노면고)다.
|
||||
* 계산 불가(계획고 없음·샘플 부족·잘못된 유형)면 던진다 — 호출부가 그 측점을 건너뛴다.
|
||||
*/
|
||||
export function computeCrossDesign(
|
||||
samples: CrossGroundSample[],
|
||||
designElevationM: number | null | undefined,
|
||||
options: CrossDesignOptions,
|
||||
): CrossDesignResult {
|
||||
const groundType = options.groundType;
|
||||
const sectionMode = options.sectionMode;
|
||||
const ditchType = options.ditchType ?? "standard";
|
||||
const paved = Boolean(options.paved);
|
||||
if (!(groundType in GROUND_TYPE_PRESET)) {
|
||||
throw new Error(`지원하지 않는 지반유형입니다: ${groundType}`);
|
||||
}
|
||||
if (!SECTION_MODES.includes(sectionMode)) {
|
||||
throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`);
|
||||
}
|
||||
if (!DITCH_TYPES.includes(ditchType)) {
|
||||
throw new Error(`지원하지 않는 측구 형식입니다: ${ditchType}`);
|
||||
}
|
||||
if (designElevationM === null || designElevationM === undefined) {
|
||||
throw new Error("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다.");
|
||||
}
|
||||
const drop = Math.max(options.surfaceDropM ?? 0, 0);
|
||||
const centerElevation = designElevationM - drop;
|
||||
|
||||
const presetKey = GROUND_TYPE_PRESET[groundType];
|
||||
if (ditchType === "l_type" && presetKey !== "rock") {
|
||||
throw new Error("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.");
|
||||
}
|
||||
const group = resolveGroup(presetKey, options.standard);
|
||||
const pavedGroup = resolveGroup("paved", options.standard);
|
||||
// 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다.
|
||||
const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct;
|
||||
const resolvedDitchSide = resolveDitchSide(sectionMode, options.ditchSide);
|
||||
|
||||
const valid: Array<[number, number]> = samples
|
||||
.filter(
|
||||
(sample) =>
|
||||
sample.valid !== false &&
|
||||
sample.offset_m !== null &&
|
||||
sample.offset_m !== undefined &&
|
||||
sample.elevation_m !== null &&
|
||||
sample.elevation_m !== undefined,
|
||||
)
|
||||
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number])
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
if (valid.length < 2) {
|
||||
throw new Error("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.");
|
||||
}
|
||||
|
||||
const groundAt = groundInterpolator(valid);
|
||||
const rockBoundaryOffsetM = options.rockBoundaryOffsetM ?? null;
|
||||
// 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다.
|
||||
const enableTwoStage =
|
||||
presetKey === "rock" && (options.twoStageSlope ?? true) && rockBoundaryOffsetM !== null;
|
||||
const geometry = new SectionGeometry({
|
||||
designElevationM: centerElevation,
|
||||
group,
|
||||
sectionMode,
|
||||
ditchSide: resolvedDitchSide,
|
||||
ditchType,
|
||||
crossSlopePct,
|
||||
groundAt,
|
||||
soilCutRatio: resolveGroup("soil", options.standard).cut_slope_ratio,
|
||||
rockBoundaryOffsetM,
|
||||
twoStageSlope: enableTwoStage,
|
||||
ditchEnabled: options.ditchEnabled ?? null,
|
||||
});
|
||||
|
||||
// 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만).
|
||||
const minOffset = valid[0][0];
|
||||
const maxOffset = valid[valid.length - 1][0];
|
||||
const mergedSet = new Set<number>(valid.map(([offset]) => round6(offset)));
|
||||
for (const point of geometry.breakpoints()) {
|
||||
if (point >= minOffset && point <= maxOffset) mergedSet.add(round6(point));
|
||||
}
|
||||
const merged = [...mergedSet].sort((a, b) => a - b);
|
||||
|
||||
const offsets: number[] = [];
|
||||
const diffs: number[] = [];
|
||||
const designLine: CrossDesignEdge[] = [];
|
||||
for (const offsetM of merged) {
|
||||
const groundM = groundAt(offsetM);
|
||||
const designZ = geometry.designZ(offsetM, groundM);
|
||||
offsets.push(offsetM);
|
||||
diffs.push(groundM - designZ);
|
||||
designLine.push({ offset_m: round4(offsetM), elevation_m: round4(designZ) });
|
||||
}
|
||||
|
||||
// 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음).
|
||||
const [cutArea, fillArea] = trapezoidAreas(offsets, diffs);
|
||||
const fillGroundSlope = geometry.fillGroundSlope();
|
||||
const slopeUnclosed =
|
||||
diffs.length > 0 &&
|
||||
(Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M ||
|
||||
Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M);
|
||||
|
||||
// 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암.
|
||||
let cutSoilArea: number;
|
||||
let cutRockArea: number;
|
||||
let cutRockKind: string | null;
|
||||
if (presetKey !== "rock") {
|
||||
cutSoilArea = cutArea;
|
||||
cutRockArea = 0;
|
||||
cutRockKind = null;
|
||||
} else if (rockBoundaryOffsetM === null) {
|
||||
cutSoilArea = 0;
|
||||
cutRockArea = cutArea;
|
||||
cutRockKind = groundType;
|
||||
} else {
|
||||
[cutSoilArea, cutRockArea] = splitCutAreas(offsets, diffs, Math.abs(rockBoundaryOffsetM));
|
||||
cutRockKind = groundType;
|
||||
}
|
||||
|
||||
// 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사.
|
||||
let ditchArea: number;
|
||||
let ditchSpec: Record<string, unknown>;
|
||||
if (!geometry.hasDitch) {
|
||||
ditchArea = 0;
|
||||
ditchSpec = { type: "none" };
|
||||
} else if (ditchType === "l_type") {
|
||||
ditchArea = (group.l_ditch_width_m * group.l_ditch_depth_m) / 2;
|
||||
ditchSpec = {
|
||||
type: "l_type",
|
||||
width_m: group.l_ditch_width_m,
|
||||
depth_m: group.l_ditch_depth_m,
|
||||
};
|
||||
} else {
|
||||
ditchArea = ((group.ditch_top_width_m + group.ditch_bottom_width_m) / 2) * group.ditch_depth_m;
|
||||
ditchSpec = {
|
||||
type: "standard",
|
||||
top_width_m: group.ditch_top_width_m,
|
||||
bottom_width_m: group.ditch_bottom_width_m,
|
||||
depth_m: group.ditch_depth_m,
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo 한다(D-2).
|
||||
let resolvedMode: string;
|
||||
if (geometry.leftRole === "cut" && geometry.rightRole === "cut") resolvedMode = "both_cut";
|
||||
else if (geometry.leftRole === "fill" && geometry.rightRole === "fill")
|
||||
resolvedMode = "both_fill";
|
||||
else if (geometry.leftRole === "cut") resolvedMode = "left_cut";
|
||||
else resolvedMode = "right_cut";
|
||||
|
||||
const result: CrossDesignResult = {
|
||||
ground_type: groundType,
|
||||
geometry_preset: presetKey,
|
||||
section_mode: resolvedMode,
|
||||
ditch_side: resolvedDitchSide,
|
||||
ditch_type: geometry.hasDitch ? ditchType : null,
|
||||
cut_slope_ratio: round4(geometry.cutRatio),
|
||||
soil_cut_slope_ratio: round4(geometry.soilCutRatio),
|
||||
two_stage_slope: geometry.twoStage,
|
||||
fill_slope_ratio: round4(geometry.fillRatio),
|
||||
roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent),
|
||||
carriageway_width_m: round4(group.road_width_m),
|
||||
cross_slope_pct: round4(crossSlopePct),
|
||||
ditch: ditchSpec,
|
||||
ditch_enabled: geometry.hasDitch,
|
||||
paved,
|
||||
road_edges: {
|
||||
left: {
|
||||
offset_m: round4(geometry.leftExtent),
|
||||
elevation_m: round4(geometry.roadZ(geometry.leftExtent)),
|
||||
},
|
||||
right: {
|
||||
offset_m: round4(-geometry.rightExtent),
|
||||
elevation_m: round4(geometry.roadZ(-geometry.rightExtent)),
|
||||
},
|
||||
},
|
||||
carriageway_edges: {
|
||||
left: {
|
||||
offset_m: round4(geometry.halfRoad),
|
||||
elevation_m: round4(geometry.roadZ(geometry.halfRoad)),
|
||||
},
|
||||
right: {
|
||||
offset_m: round4(-geometry.halfRoad),
|
||||
elevation_m: round4(geometry.roadZ(-geometry.halfRoad)),
|
||||
},
|
||||
},
|
||||
design_elevation_m: round4(centerElevation),
|
||||
cut_area_m2: round4(cutArea),
|
||||
cut_soil_area_m2: round4(cutSoilArea),
|
||||
cut_rock_area_m2: round4(cutRockArea),
|
||||
cut_rock_kind: cutRockKind,
|
||||
fill_area_m2: round4(fillArea),
|
||||
slope_unclosed: slopeUnclosed,
|
||||
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
|
||||
ditch_area_m2: round4(ditchArea),
|
||||
design_line: designLine,
|
||||
};
|
||||
if (drop > 0) result.surface_drop_m = round4(drop);
|
||||
if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m);
|
||||
if (presetKey === "rock" && rockBoundaryOffsetM !== null) {
|
||||
result.rock_boundary_offset_m = round4(rockBoundaryOffsetM);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 짝: 파이썬 `round(offset, 6)` — 병합 격자 중복 제거 기준. */
|
||||
function round6(value: number): number {
|
||||
return Math.round(value * 1e6) / 1e6;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* common_util_cross_design_areas.ts
|
||||
* 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리.
|
||||
*
|
||||
* ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠
|
||||
* 짝: `B06_Section/B06_Section_Engine_Areas.py`
|
||||
* 같은 입력에 같은 값을 내야 한다. 회귀 테스트가 두 구현을 실제로 비교한다:
|
||||
* `tmp/tests/test_b06_cross_design_mirror.py` — 고칠 때 반드시 같이 돌릴 것.
|
||||
* 왜 두 벌인가: 사용자 조작 중 계산은 브라우저 안에서 끝나야 하고(2026-09-03 사용자
|
||||
* 확정), 저장·확정·도면 산출은 서버가 정본으로 다시 계산하기 때문이다.
|
||||
*
|
||||
* 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로 설계 로직을 전혀 모른다.
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* 오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 [절토, 성토] 면적을 낸다.
|
||||
*
|
||||
* diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서
|
||||
* 나눠 절·성토가 섞이지 않게 한다.
|
||||
*/
|
||||
export function trapezoidAreas(offsets: number[], diffs: number[]): [number, number] {
|
||||
let cutArea = 0;
|
||||
let fillArea = 0;
|
||||
for (let index = 1; index < offsets.length; index += 1) {
|
||||
const x0 = offsets[index - 1];
|
||||
const x1 = offsets[index];
|
||||
const d0 = diffs[index - 1];
|
||||
const d1 = diffs[index];
|
||||
const width = x1 - x0;
|
||||
if (width <= 0) continue;
|
||||
if (d0 === 0 && d1 === 0) continue;
|
||||
if (d0 * d1 < 0) {
|
||||
// 부호 변화: 영교점에서 두 삼각형으로 분리
|
||||
const zeroRatio = d0 / (d0 - d1);
|
||||
const xZero = x0 + width * zeroRatio;
|
||||
const leftArea = 0.5 * (xZero - x0) * Math.abs(d0);
|
||||
const rightArea = 0.5 * (x1 - xZero) * Math.abs(d1);
|
||||
if (d0 > 0) {
|
||||
cutArea += leftArea;
|
||||
fillArea += rightArea;
|
||||
} else {
|
||||
fillArea += leftArea;
|
||||
cutArea += rightArea;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const area = 0.5 * (d0 + d1) * width;
|
||||
if (area >= 0) cutArea += area;
|
||||
else fillArea += -area;
|
||||
}
|
||||
return [cutArea, fillArea];
|
||||
}
|
||||
|
||||
/**
|
||||
* 절토 면적을 암반 경계선 기준으로 [토사, 암반]으로 나눈다.
|
||||
*
|
||||
* 암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께
|
||||
* `t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에
|
||||
* 대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상
|
||||
* `max(d, 0)`이라 `trapezoidAreas`의 절토 면적과 정확히 일치한다.
|
||||
*
|
||||
* 두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야
|
||||
* 사다리꼴 적분이 근사가 아닌 정확값이 된다.
|
||||
*/
|
||||
export function splitCutAreas(
|
||||
offsets: number[],
|
||||
diffs: number[],
|
||||
soilDepthM: number,
|
||||
): [number, number] {
|
||||
const t0 = Math.max(soilDepthM, 0);
|
||||
let soilArea = 0;
|
||||
let rockArea = 0;
|
||||
for (let index = 1; index < offsets.length; index += 1) {
|
||||
const x0 = offsets[index - 1];
|
||||
const x1 = offsets[index];
|
||||
const d0 = diffs[index - 1];
|
||||
const d1 = diffs[index];
|
||||
const width = x1 - x0;
|
||||
if (width <= 0) continue;
|
||||
const ratios = [0, 1];
|
||||
for (const level of [0, t0]) {
|
||||
if ((d0 - level) * (d1 - level) < 0) ratios.push((level - d0) / (d1 - d0));
|
||||
}
|
||||
ratios.sort((a, b) => a - b);
|
||||
for (let step = 1; step < ratios.length; step += 1) {
|
||||
const ratioA = ratios[step - 1];
|
||||
const ratioB = ratios[step];
|
||||
const span = width * (ratioB - ratioA);
|
||||
if (span <= 0) continue;
|
||||
const dA = d0 + (d1 - d0) * ratioA;
|
||||
const dB = d0 + (d1 - d0) * ratioB;
|
||||
soilArea += ((Math.min(Math.max(dA, 0), t0) + Math.min(Math.max(dB, 0), t0)) / 2) * span;
|
||||
rockArea += ((Math.max(dA - t0, 0) + Math.max(dB - t0, 0)) / 2) * span;
|
||||
}
|
||||
}
|
||||
return [soilArea, rockArea];
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/* =============================================================================
|
||||
* ui_template_compass.css
|
||||
* 3D 뷰어 방위 나침반(`ui_template_compass.ts`) 전용 스타일.
|
||||
*
|
||||
* B04 지표면 뷰어에만 있던 것을 B05 종단 3D 뷰어와 함께 쓰려고 공용으로 옮겼다
|
||||
* (2026-09-03). 색상 하드코딩 금지 원칙대로 전부 토큰을 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
/* 3D 뷰어 방위 나침반 — 모양·색만 여기서 정한다.
|
||||
**놓을 자리는 페이지 CSS 몫**이다(B04 우하단, B05 는 ISO 버튼 아래). 위치를 여기 박으면
|
||||
페이지마다 덮어써야 해서 규칙이 엉킨다. */
|
||||
.ui-compass {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 1px 2px var(--color-surface-raised));
|
||||
}
|
||||
|
||||
/* inline SVG의 baseline 여백을 없앤다 — 안 그러면 아래로 5px 떠 모서리 여백이 어긋난다. */
|
||||
.ui-compass svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 지면에 누운 나침반 링 — 좌표는 카메라 자세대로 매 프레임 다시 쓰인다.
|
||||
링 안쪽 면은 지형이 비치도록 아주 옅게만 깔아 도면 위 방위표처럼 읽힌다. */
|
||||
.ui-compass-face {
|
||||
fill: var(--color-surface-raised);
|
||||
fill-opacity: 0.5;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.ui-compass-ring {
|
||||
fill: none;
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.ui-compass-tick {
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.ui-compass-tick--major {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
/* 도북 화살 — 이 위젯에서 가장 먼저 읽혀야 하는 하나. */
|
||||
.ui-compass-arrow {
|
||||
fill: var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
/* 표고축 — 내려다볼수록 짧아져 시점 기울기를 한 번 더 알린다. */
|
||||
.ui-compass-pole {
|
||||
stroke: var(--color-text-body);
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.ui-compass-label {
|
||||
fill: var(--color-text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.ui-compass-label--north {
|
||||
fill: var(--color-danger, #d64545);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
/* B04 지표면 3D 뷰어 방위 표시 — **지면에 누운 나침반 링**(2026-09-03 사용자 선택).
|
||||
/* 3D 뷰어 방위 표시 — **지면에 누운 나침반 링**(2026-09-03 사용자 선택).
|
||||
*
|
||||
* B04 지표면 뷰어 전용이었으나 B05 종단 3D 뷰어도 같은 표시가 필요해져 공용으로 옮겼다
|
||||
* (2026-09-03). 스타일은 이 모듈이 직접 물고 오고(`ui_template_compass.css`), **놓을 자리만**
|
||||
* 페이지가 클래스로 준다 — B04 는 우하단(축척 막대와 짝), B05 는 ISO 버튼 아래다.
|
||||
*
|
||||
* 종전에는 방위각만으로 도는 평면 콤파스였다. 3D 화면은 기울어 있어 남북이 화면에서
|
||||
* 눌리는데(사시도 앙각 27.7° → 0.47배) 바늘은 안 눌린 각도로 돌아, 실제 화면 북쪽과
|
||||
@@ -15,6 +19,8 @@
|
||||
* 뷰어 파일이 이미 900줄을 넘어 여기로 뺐다(CLAUDE.md 4장 700줄 제한).
|
||||
*/
|
||||
|
||||
import "./ui_template_compass.css";
|
||||
|
||||
export interface TerrainCompass {
|
||||
root: HTMLElement;
|
||||
/** 카메라 오프셋(카메라 위치 − 타깃)으로 링·화살·표고침을 맞춘다. */
|
||||
@@ -22,7 +28,8 @@ export interface TerrainCompass {
|
||||
setVisible(visible: boolean): void;
|
||||
}
|
||||
|
||||
const SIZE_PX = 84;
|
||||
/** 기본 크기(px). 호출부가 `sizePx` 로 덮을 수 있다 — B05 는 조금 크게 쓴다. */
|
||||
const DEFAULT_SIZE_PX = 84;
|
||||
const VIEW_BOX = 104;
|
||||
const CENTER = 0;
|
||||
const RING_RADIUS = 34;
|
||||
@@ -56,25 +63,32 @@ function svg(tag: string, attributes: Record<string, string>): SVGElement {
|
||||
return element;
|
||||
}
|
||||
|
||||
export function createTerrainCompass(): TerrainCompass {
|
||||
export interface TerrainCompassOptions {
|
||||
/** 위젯 한 변의 크기(px). 기본 84. */
|
||||
sizePx?: number;
|
||||
/** 놓을 자리를 정하는 페이지 클래스 — 위치 지정은 페이지 CSS 몫이다. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function createTerrainCompass(options: TerrainCompassOptions = {}): TerrainCompass {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b04-surface__compass";
|
||||
root.className = options.className ? `ui-compass ${options.className}` : "ui-compass";
|
||||
root.hidden = true;
|
||||
root.title = "도북(N) — 링이 지면에 누워 시점 기울기를 함께 보여 준다";
|
||||
|
||||
const half = VIEW_BOX / 2;
|
||||
const canvas = svg("svg", {
|
||||
viewBox: `${-half} ${-half} ${VIEW_BOX} ${VIEW_BOX}`,
|
||||
width: String(SIZE_PX),
|
||||
height: String(SIZE_PX),
|
||||
width: String(options.sizePx ?? DEFAULT_SIZE_PX),
|
||||
height: String(options.sizePx ?? DEFAULT_SIZE_PX),
|
||||
"aria-hidden": "true",
|
||||
});
|
||||
const face = svg("path", { class: "b04-surface__compass-face", d: "" });
|
||||
const ring = svg("path", { class: "b04-surface__compass-ring", d: "" });
|
||||
const face = svg("path", { class: "ui-compass-face", d: "" });
|
||||
const ring = svg("path", { class: "ui-compass-ring", d: "" });
|
||||
const ticks: SVGElement[] = [];
|
||||
for (let bearing = 0; bearing < 360; bearing += TICK_STEP_DEG) {
|
||||
const tick = svg("line", {
|
||||
class: `b04-surface__compass-tick${bearing % 90 === 0 ? " b04-surface__compass-tick--major" : ""}`,
|
||||
class: `ui-compass-tick${bearing % 90 === 0 ? " ui-compass-tick--major" : ""}`,
|
||||
x1: "0",
|
||||
y1: "0",
|
||||
x2: "0",
|
||||
@@ -82,9 +96,9 @@ export function createTerrainCompass(): TerrainCompass {
|
||||
});
|
||||
ticks.push(tick);
|
||||
}
|
||||
const arrow = svg("path", { class: "b04-surface__compass-arrow", d: "" });
|
||||
const arrow = svg("path", { class: "ui-compass-arrow", d: "" });
|
||||
const pole = svg("line", {
|
||||
class: "b04-surface__compass-pole",
|
||||
class: "ui-compass-pole",
|
||||
x1: "0",
|
||||
y1: "0",
|
||||
x2: "0",
|
||||
@@ -92,7 +106,7 @@ export function createTerrainCompass(): TerrainCompass {
|
||||
});
|
||||
const labels = CARDINALS.map(([name]) =>
|
||||
svg("text", {
|
||||
class: `b04-surface__compass-label${name === "N" ? " b04-surface__compass-label--north" : ""}`,
|
||||
class: `ui-compass-label${name === "N" ? " ui-compass-label--north" : ""}`,
|
||||
x: "0",
|
||||
y: "0",
|
||||
}),
|
||||
@@ -100,7 +114,7 @@ export function createTerrainCompass(): TerrainCompass {
|
||||
labels.forEach((label, index) => {
|
||||
label.textContent = CARDINALS[index][0];
|
||||
});
|
||||
const poleLabel = svg("text", { class: "b04-surface__compass-label", x: "0", y: "0" });
|
||||
const poleLabel = svg("text", { class: "ui-compass-label", x: "0", y: "0" });
|
||||
poleLabel.textContent = "Z";
|
||||
|
||||
canvas.append(face, ring, ...ticks, arrow, pole, ...labels, poleLabel);
|
||||
Reference in New Issue
Block a user