diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index e44dcf7b..62e63d40 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -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 지도 --- */ diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index c3f278e6..86e879a3 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -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) diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index c38a6a8f..fc7e51db 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -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( diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 6ffbde49..eff21e17 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -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 { 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; diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 5f7f7c68..f6166abe 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -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 사용자 지시로 삭제했다. 편집 차단(강제)은 diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index e6892e8e..dd5325b8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -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), diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index b31ac9ce..42c0cdfc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 5d127f36..37e81e3d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -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로 폴백한다. */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index a22a7aaa..f886aa09 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -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, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index f2f34eec..10e6018d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 diff --git a/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts b/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts new file mode 100644 index 00000000..c8db8b66 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts @@ -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; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Straighten.ts b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts index 1bcc587e..5b9279de 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Straighten.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts @@ -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( diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index e42c66a6..47e1e13f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index 94062e8f..af9ef682 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Style_MassHaul.css b/B05_Profile/B05_Profile_UI_Style_MassHaul.css index 5ee9930a..c482c4ca 100644 --- a/B05_Profile/B05_Profile_UI_Style_MassHaul.css +++ b/B05_Profile/B05_Profile_UI_Style_MassHaul.css @@ -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); diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 51cd5ea1..d098c6df 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -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(); diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 95021b69..28dee239 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -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, previous: CrossSection["design"], ): NonNullable { 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; + const source = previous as unknown as Record; + for (const key of PRESERVED_KEYS) { + if (source[key] !== undefined) merged[key] = source[key]; + } + return merged as unknown as NonNullable; } /** @@ -63,8 +94,93 @@ function preserveUserFields( * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { + 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; + // 암 경계는 세션 조정값 → 저장분 → 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, + previous, + ); + updated.push(section.chainage_m); + } + return updated; +} + +/** 옛 데이터 폴백 — 선형 저장분이 없어 브라우저가 계획고를 풀 수 없을 때만 쓴다. */ +async function refreshFromServer(input: CrossRefreshInput): Promise { const { projectId, routeId, detail, edits, shouldApply } = input; - // full_designs — 설계선 좌표까지 받아야 3D 코리도·횡단도가 편집 즉시 같은 형상이 된다. const response = await previewCrossDesigns( projectId, routeId, diff --git a/B06_Section/B06_Section_Engine_Areas.py b/B06_Section/B06_Section_Engine_Areas.py index e7ada04d..af308b3c 100644 --- a/B06_Section/B06_Section_Engine_Areas.py +++ b/B06_Section/B06_Section_Engine_Areas.py @@ -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줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이 넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로 설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다. diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index b54486ae..a9fa8a11 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -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 상세설계에서 diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index c939680c..9c7a7a16 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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() diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index bccbab34..7cd9fa6a 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -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 { */ async function reconcileStaleDesigns(): Promise { 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 { }; } | 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 { // 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 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) { diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 09a7c62a..ffa54a7a 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -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>(); + +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; } /** diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index edf02dfa..cdb9dd5e 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -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( diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index c1d39870..a74ccf3b 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -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, ): StandardPanelController { + // 브라우저 횡단 계산이 패널 없이도 config 기본값을 쓸 수 있게 먼저 기억해 둔다. + rememberStandardDefaults(projectId, defaults); // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. const sessionValue = readStandardCrossSession(projectId); const hadSession = sessionValue !== null; diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts new file mode 100644 index 00000000..730670a5 --- /dev/null +++ b/common_util/common_util_cross_design.ts @@ -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 = { + 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; + +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; + 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(); + private cutCross = new Map(); + private fillCross = new Map(); + + 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(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; + 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; +} diff --git a/common_util/common_util_cross_design_areas.ts b/common_util/common_util_cross_design_areas.ts new file mode 100644 index 00000000..081cea98 --- /dev/null +++ b/common_util/common_util_cross_design_areas.ts @@ -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]; +} diff --git a/ui_template/ui_template_compass.css b/ui_template/ui_template_compass.css new file mode 100644 index 00000000..9cd28c73 --- /dev/null +++ b/ui_template/ui_template_compass.css @@ -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); +} diff --git a/B04_PreProcess/B04_PreProcess_UI_Compass.ts b/ui_template/ui_template_compass.ts similarity index 83% rename from B04_PreProcess/B04_PreProcess_UI_Compass.ts rename to ui_template/ui_template_compass.ts index 09113347..9bd58b68 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Compass.ts +++ b/ui_template/ui_template_compass.ts @@ -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): 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);