Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-09-03 20:57:52 +09:00
28 changed files with 1637 additions and 196 deletions
+2 -59
View File
@@ -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)
+20 -11
View File
@@ -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(
+9
View File
@@ -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 사용자 지시로 삭제했다. 편집 차단(강제)은
+43 -7
View File
@@ -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),
+24 -5
View File
@@ -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 {
+27 -6
View File
@@ -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,
});
+26 -4
View File
@@ -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(
+22 -4
View File
@@ -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;
+34
View File
@@ -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);
+16 -1
View File
@@ -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();
+142 -26
View File
@@ -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,
+5
View File
@@ -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줄을 넘겨, 설계선을 어떻게 세우나(그쪽) 선과 지반 사이
넓이를 어떻게 재나(여기) 갈랐다. 함수 모두 지반선과 설계선의 **차이 배열** 받으므로
설계 로직을 전혀 모른다 그래서 따로 떼어 검산하기도 쉽다.
+12
View File
@@ -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 상세설계에서
+6 -1
View File
@@ -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()
+17 -5
View File
@@ -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) {
+81 -41
View File
@@ -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;
+728
View File
@@ -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];
}
+71
View File
@@ -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);