/* ============================================================================= * B06_Section_UI_Cross_Culvert_Wire.ts * 횡단 카드의 배수관 세트 **계산 호출 + 이동량 되받기(토스트)** — Cross_View에서 * 분리(700줄 제한). 카드가 다시 그려질 때마다 한 번 불린다. * ========================================================================== */ import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert"; import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import type { BasinAdjust, CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert_Types"; import { basinSpanOfSpec, FILL_SLOPE_RATIO_MIN, materialLabel, materialLimit, revetSpanOfSpec, } from "./B06_Section_UI_Cross_Culvert_Const"; import { EXTRA_SPAN_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; import type { StructureSpan } from "./B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { designInterpolator, groundInterpolator, slopeToeOffset, } from "./B06_Section_UI_Cross_Culvert_Solve"; /** * 기슭막이 4축 조작 제어(2026-08-22 확정) — 벽을 눌러 고르고 조정창에서 * 좌우(x)·상하(d, 성토선 대각)·높이(h)·재질(m)을 만진다. 고른 벽은 여기 담아 두어 * 카드가 다시 그려져도 되살아난다. `select`는 값만 담고 다시 그리지 않는다. */ export interface RevetOffsetControl { adjustFor: (section: CrossSection, role: RevetKey) => WallAdjust; /** 이 측점이 **실제로 손댄** 값만 — 없으면 null(자동·상속과 구분해야 한다). */ storedAdjustFor: (section: CrossSection, role: RevetKey) => WallAdjust | null; selectedFor: (section: CrossSection) => RevetKey | null; /** 이 카드에서 **강조**할 벽 — 연동으로 이어진 옆 측점이 고른 벽도 켠다 * (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다). * 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */ highlightFor: (section: CrossSection) => RevetKey | null; select: (chainageM: number, key: RevetKey | null) => void; /** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */ onSelectionChange?: () => void; /** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */ syncApplied: (chainageM: number, role: RevetKey, applied: WallAdjust) => void; /** 일부 축만 바꾼다 — 나머지는 유지. h: null = 기본값 복귀. */ update: (chainageM: number, role: RevetKey, patch: Partial) => void; reset: (chainageM: number, role: RevetKey) => void; } /** 구간값을 갖는 요소 — 유입·유출 기슭막이, 유입 집수정(2026-08-24). */ export type SpanRole = "inlet" | "outlet" | "basin"; /** 길이와 기준측점 전/후. 세 값이 곧 종방향 범위(폭)다 — 별도 폭 필드는 없다. */ export interface SpanValues { lengthM: number; beforeM: number; afterM: number; } /** * 기슭막이·집수정 **구간값 제어**(2026-08-24 사용자) — 조정창에서 길이·전·후를 * 만진다. 원천은 배수관 옵션(`pipe_points`) 하나이고, 편집은 캐시를 고쳐 즉시 * 보여준 뒤 늦게 한 번 저장한다. 링크 측점에서는 **소유 측점 값**을 만진다. */ export interface StructureSpanControl { /** 이 카드가 구간값을 만질 대상 측점 — 링크면 소유 측점, 아니면 자기 자신. */ ownerOf: (section: CrossSection) => CrossSection | null; valuesFor: (section: CrossSection, role: SpanRole) => SpanValues | null; update: (section: CrossSection, role: SpanRole, patch: Partial) => void; /** 다단 한 단의 구간값 — 기준벽과 달리 **단마다 따로** 잡는다(2026-08-29 사용자). * 값은 세션에 담기고 [저장]·[확정] 때 정본으로 간다. 없으면 기본 10m(5/5). */ tierValuesFor: (section: CrossSection, key: RevetKey) => SpanValues; updateTier: (section: CrossSection, key: RevetKey, patch: Partial) => void; } /** * 연동 기슭막이 옵션 제어(2026-08-24 사용자) — 연동은 **측점별**, 종단경사 반영은 * 기슭막이 한 벌 **전체 공통**(소유 측점 1개)이다. */ export interface RevetLinkControl { /** 연동 여부 — 기본 켬. 끄면 이 측점에서 4축을 따로 잡는다. */ linkedFor: (section: CrossSection) => boolean; setLinked: (chainageM: number, linked: boolean) => void; /** 종단경사 반영 여부 — 소유 측점에 하나. 기본 켬. */ followGradeFor: (section: CrossSection) => boolean; setFollowGrade: (section: CrossSection, follow: boolean) => void; } /** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22 사용자). 세션 보관은 Page가 한다. */ export interface InletStructureControl { valueFor: (section: CrossSection) => InletStructureChoice; adjustFor: (section: CrossSection) => BasinAdjust; set: (chainageM: number, value: InletStructureChoice) => void; updateAdjust: (chainageM: number, patch: Partial) => void; resetAdjust: (chainageM: number) => void; } /** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */ /** 다단 기슭막이가 붙는 쪽 — 유출 성토부 / 집수정 계류측. */ export type ExtraSide = "outlet" | "basin"; export interface ExtraWallControl { /** side: "outlet" = 유출 다단, "basin" = 집수정 계류측 다단(2026-08-22). */ countFor: (section: CrossSection, side?: ExtraSide) => number; /** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */ setCount: (chainageM: number, count: number, side?: ExtraSide) => void; /** 다단 등간격 배치 요청(2026-08-22 ①) — 다음 계산 1회에 적용된다. */ equalize: (chainageM: number) => void; /** 등간격 요청을 소비(1회성) — 계산 직전에 Wire가 부른다. */ consumeEqualize: (section: CrossSection) => boolean; /** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */ syncCount: (chainageM: number, built: number, side?: ExtraSide) => void; } import { showToast } from "@ui/ui_template_elements"; import { L } from "./B06_Section_UI_Section_Common"; /** * 배수관 세트 기하 계산 + 제어 상태 동기화. * * ① 세션의 이동량(기슭막이·추가 벽)·유입 구조물 선택을 모아 기하를 계산하고, * ② 성토부가 짧아져 못 세운 추가 벽은 개수를 잘라 동기화하며(고아 이동량 방지), * ③ 요청 이동량이 한계에 잘렸으면 실제 적용값으로 되돌려 담는다 — 안 그러면 눌러도 * 안 움직이는데 창의 숫자만 계속 커진다(2026-08-21 사용자 지적). 잘린 순간에는 * **왜 안 움직였는지 토스트**로 알린다(2026-08-22 사용자 — 13+15.7처럼 자동 * 자리가 이미 안쪽 한계라 ◀가 그대로 먹히지 않는 경우). 조작 중(그 벽이 선택된 * 상태)일 때만 띄운다 — 리로드로 복원된 옛 세션 값에는 침묵. */ /** * 구조물이 **인접 측점까지 넘어온** 경우의 링크(2026-08-24 사용자). * * 기슭막이 기본 연장이 10m(전 5·후 5)라 배수관 측점 하나만으로는 끝나지 않고 옆 * 측점을 덮는다. 그 측점 횡단도에도 같은 기슭막이가 보여야 하고, 3D 코리도가 이미 * 그 구간을 소유 측점 단면으로 채우고 있으므로 **2D도 같은 규칙(복사 + 종단 보정)** * 이라야 어긋나지 않는다. 조작·저장은 소유 측점에서만 한다(링크 카드는 표시 전용). */ export interface CulvertLink { /** 구조물이 실제로 선 측점. */ source: CrossSection; /** 이 측점 계획고 − 소유 측점 계획고. 벽을 종단 기울기만큼 올려/내려 얹는다. */ dz: number; /** 소유 측점과의 거리(m) — 요소별 연장 안에 드는지 판정에 쓴다. */ distanceM: number; /** 소유 측점 기준 부호 있는 위치(m). 음수 = 전(前), 양수 = 후(後). 전/후 몫이 * 다른 요소(집수정 1/1, 기슭막이 5/5)를 어느 쪽 몫으로 잴지 가른다(2026-08-24). */ deltaM: number; } /** * 다단 한 단의 구간값 — 단별로 따로 잡은 값(`design.extra_spans`)이 있으면 그것, * **없으면 소유 벽 연장을 상속**한다(2026-08-30 사용자: 손대기 전에는 기준벽과 같이 * 옆 측점에 서야 한다). 고정 기본 10m(5/5)는 소유 벽 제원조차 없을 때의 마지막 값이다. * 손으로 한 번 잡은 단은 그 값으로 고정된다 — 계곡부·능선부에서 단마다 연장이 다르다. */ /** 「단이 못 섰다」 안내를 이미 낸 자리 — 다시 그릴 때마다 뜨지 않게 한 번만 낸다. * 키에 세워진 단 수를 넣어, 지형·조작이 바뀌어 결과가 달라지면 다시 알린다. */ const extraLimitNotified = new Set(); /** 요청 단 수보다 적게 섰음을 알린다 — 조정창을 열지 않은 경로(좌측 폼 등)에서도 뜬다. * 종전에는 `activeRevet` 이 그 벽일 때만 떠서, 다른 길로 단 수를 바꾸면 조용히 잘렸다 * (2026-09-07). 문구는 종전 그대로 쓴다. */ function noticeExtraLimit(chainageM: number, side: "outlet" | "basin", built: number): void { const key = `${chainageM.toFixed(2)}|${side}|${built}`; if (extraLimitNotified.has(key)) return; extraLimitNotified.add(key); showToast(L("B06_Cross_Extra_Limit").replace("{n}", String(built)), "info"); } /** 요청대로 다 선 자리는 안내 기록을 지운다 — 다음에 또 모자라면 다시 알린다. */ function clearExtraLimitNotice(chainageM: number, side: "outlet" | "basin"): void { const head = `${chainageM.toFixed(2)}|${side}|`; for (const key of [...extraLimitNotified]) { if (key.startsWith(head)) extraLimitNotified.delete(key); } } export function tierSpanOf(section: CrossSection, key: string): StructureSpan { const stored = section.design?.extra_spans?.[key]; if (stored) return { beforeM: Math.max(stored.before_m, 0), afterM: Math.max(stored.after_m, 0) }; // 유출측 다단(extra…)은 유출 벽, 유입측 다단(bextra…)은 유입 벽(집수정이면 집수정) // 연장을 따른다 — 그 벽에 딸린 성토부 계단이기 때문이다. const spec = key.startsWith("bextra") ? section.culvert?.inlet : section.culvert?.outlet; if (!spec) return { ...EXTRA_SPAN_DEFAULT }; return spec.structure === "집수정" ? basinSpanOfSpec(spec) : revetSpanOfSpec(spec); } /** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이·집수정·다단 중 큰 값. */ export function culvertReach(section: CrossSection): StructureSpan | null { const culvert = section.culvert; if (!culvert) return null; let beforeM = 0; let afterM = 0; for (const spec of [culvert.inlet, culvert.outlet]) { if (!spec) continue; // 집수정이 선 쪽에는 기슭막이가 없다 — 기슭막이 기본 10m를 얹으면 있지도 않은 // 벽의 연장으로 옆 측점이 링크된다. const span = spec.structure === "집수정" ? basinSpanOfSpec(spec) : revetSpanOfSpec(spec); beforeM = Math.max(beforeM, span.beforeM); afterM = Math.max(afterM, span.afterM); } // 다단은 기준벽보다 길 수 있다(단별 수동값 — 2026-08-29). 그 몫까지 닿아야 먼 // 측점 카드에도 그 단이 선다. for (const span of Object.values(section.design?.extra_spans ?? {})) { beforeM = Math.max(beforeM, Math.max(span.before_m, 0)); afterM = Math.max(afterM, Math.max(span.after_m, 0)); } return beforeM > 0 || afterM > 0 ? { beforeM, afterM } : null; } /** * 이 측점을 덮는 구조물 측점을 찾는다. 자기 구조물이 있으면 링크 아님(소유 측점). * 여러 구조물이 겹치면 가장 가까운 측점 — 3D `structureAt`과 같은 규칙. */ export function culvertOwnerFor( section: CrossSection, sections: readonly CrossSection[], ): CrossSection | undefined { if (section.culvert) return undefined; let best: CrossSection | undefined; let bestDistance = Number.POSITIVE_INFINITY; for (const candidate of sections) { const reach = culvertReach(candidate); if (!reach) continue; if ( section.chainage_m < candidate.chainage_m - reach.beforeM - 1e-9 || section.chainage_m > candidate.chainage_m + reach.afterM + 1e-9 ) { continue; } const distance = Math.abs(section.chainage_m - candidate.chainage_m); if (distance < bestDistance) { best = candidate; bestDistance = distance; } } return best; } /** * 그 벽(기준벽·다단)이 **이 측점 카드에도 서는가** — 연동 하이라이트 짝짓기용 * (2026-08-30 사용자). 판정은 그리기(`trimLinkedLayout`)와 같은 구간값을 쓴다: * 기준벽은 자기 길이·전/후, 다단은 단별 구간값(`tierSpanOf`). */ export function wallStandsAt(owner: CrossSection, section: CrossSection, key: string): boolean { const deltaM = section.chainage_m - owner.chainage_m; if (Math.abs(deltaM) < 0.01) return true; const spec = owner.culvert; if (!spec) return false; if (key.startsWith("extra") || key.startsWith("bextra")) { return spanCovers(tierSpanOf(owner, key), deltaM); } const side = key === "inlet" ? spec.inlet : key === "outlet" ? spec.outlet : null; if (!side) return false; // 집수정은 소유 측점 하나에만 선다(그리기와 같은 규칙) — 링크 카드에는 없다. if (side.structure === "집수정" && !spec.hidden_pipe) return false; return spanCovers(revetSpanOfSpec(side), deltaM); } /** * 링크된 관의 벽이 **이 카드에 실제로 서는가**. 하나도 안 서면 그 링크는 이 측점의 * 그림을 막을 이유가 없다 — 독립 벽(D경로)이 그려져야 한다. * * 왜 있나(2026-09-09) — 「관이 없는 측점인데도 구조물을 놓으면 아무것도 안 보인다」가 * 사용자에게 보이던 결함이었다. 겹침 방지 가드가 **링크가 있기만 하면** 막고 있었는데, * 관이 아홉·열하나인 노선에서는 링크가 거의 모든 측점을 덮어 D경로가 통째로 죽었다. * 가드를 없애지 않고 **좁힌다** — 벽이 실제로 서는 카드에서만 막는다. */ export function culvertWallsStandAt(owner: CrossSection, section: CrossSection): boolean { const keys = ["inlet", "outlet"]; const counts = owner.design?.extra_wall_counts ?? {}; for (const [side, count] of Object.entries(counts)) { const total = Math.max(Math.trunc(Number(count) || 0), 0); for (let index = 0; index < total; index += 1) { keys.push(side === "basin" ? `bextra${index}` : `extra${index}`); } } return keys.some((key) => wallStandsAt(owner, section, key)); } export function culvertLinkFor( section: CrossSection, sections: readonly CrossSection[], designZAt: (chainageM: number) => number | null, ): CulvertLink | undefined { const best = culvertOwnerFor(section, sections); if (!best) return undefined; const bestDistance = Math.abs(section.chainage_m - best.chainage_m); const here = designZAt(section.chainage_m); const there = designZAt(best.chainage_m); return { source: best, dz: here != null && there != null ? here - there : 0, distanceM: bestDistance, deltaM: section.chainage_m - best.chainage_m, }; } /** 링크 측점이 그 구간(전/후 몫) 안에 드는지 — 부호로 어느 쪽 몫인지 가른다. */ function spanCovers(span: StructureSpan, deltaM: number): boolean { return deltaM < 0 ? -deltaM <= span.beforeM + 1e-9 : deltaM <= span.afterM + 1e-9; } /** * 지운 구조물이 남긴 **설계선 트림**을 쪽별로 되돌린다(2026-08-26 사용자 지적 — * 4·14 측점 성토선 삭제). 구조물은 안 그리는데 트림만 남으면 그 측점 설계선이 * 노견 밖에서 통째로 끊긴다 — 집수정은 대체 사면선(`minSlope`/`maxSlope`)도 조작량이 * 0이면 없어서 아무것도 안 남는다. `-/+Infinity` = 트림 없음(기하가 쓰는 초기값). */ function keepTrimSides( trim: CulvertDesignTrim | null, keepMin: boolean, keepMax: boolean, ): CulvertDesignTrim | null { if (!trim || (!keepMin && !keepMax)) return null; if (keepMin && keepMax) return trim; return { minOffset: keepMin ? trim.minOffset : Number.NEGATIVE_INFINITY, maxOffset: keepMax ? trim.maxOffset : Number.POSITIVE_INFINITY, ...(keepMin && trim.minElevation != null ? { minElevation: trim.minElevation } : {}), ...(keepMax && trim.maxElevation != null ? { maxElevation: trim.maxElevation } : {}), ...(keepMin && trim.minSlope ? { minSlope: trim.minSlope } : {}), ...(keepMax && trim.maxSlope ? { maxSlope: trim.maxSlope } : {}), }; } /** * 링크 카드에서 **연장이 여기까지 오지 않는 요소**를 지운다(요소별 판정). * 유입·유출 기슭막이는 각자 길이·전/후를 갖고, 집수정은 자기 연장(기본 2m·1/1) * 만큼만 이어진다. 관은 한 측점 전용이라 그리기 단계에서 숨긴다(`hidePipe`). * * 도형뿐 아니라 그 구조물이 만든 **설계선 트림·성토부선까지 함께** 지운다 — 도형만 * 지우면 없는 구조물이 설계선을 끊는다(2026-08-26). */ function trimLinkedLayout(layout: CulvertLayout, link: CulvertLink): CulvertLayout { const spec = layout.culvert; const spanOf = (side: "inlet" | "outlet"): StructureSpan => spec[side]?.structure === "집수정" ? basinSpanOfSpec(spec[side]) : revetSpanOfSpec(spec[side]); const inletIn = spanCovers(spanOf("inlet"), link.deltaM); const outletIn = spanCovers(spanOf("outlet"), link.deltaM); // 집수정은 링크 측점에 서지 않으므로(아래 `basin: null`) 그 트림은 언제나 지운다. const keepInlet = inletIn && !layout.basin; // 좌표 규약 +offset = 좌측, outward=+1(좌) = trimMax — 유입이 어느 쪽 트림인지는 // 관 양 끝 offset 대소로 가른다(관은 노선을 가로지르므로 좌우가 갈린다). const inletIsMax = layout.pipe.inlet.offset > layout.pipe.outlet.offset; const empty = { segments: [], addable: false }; return { ...layout, walls: layout.walls.filter((wall) => (wall.role === "inlet" ? inletIn : outletIn)), // 다단은 **자기 구간값**으로 판정한다(2026-08-29 사용자 — 단마다 연장이 다르다). // 성토부선은 단이 서는 카드에서만 그린다(끝 단이 빠지면 그 아래 선도 뺀다). extraWalls: layout.extraWalls.filter((_wall, i) => spanCovers(tierSpanOf(link.source, `extra${i}`), link.deltaM), ), // 성토부선은 **기준벽** 소유다 — 단 구간값으로 잠그면 기준벽이 선 카드에서도 // 벽 아래 성토선이 사라진다(2026-08-30 사용자 지적: 우측 링크 카드에 성토 없음). outletFill: outletIn ? layout.outletFill : empty, // 집수정은 **소유 측점 횡단도 하나에만** 선다(2026-08-24 사용자 확정) — 기슭막이와 // 달리 옆 측점으로 이어지는 구조물이 아니다. 계류측 다단·성토부선도 집수정에 딸린 // 것이라 함께 뺀다. 3D에서는 소유 측점 하나가 자기 길이(기본 2m)만큼 스윕한다. // 단 독립 기슭막이(관 숨김)는 집수정이 없고 이 채널이 **유입측 벽의 다단·성토부선** // 이라, 유출측과 같은 규칙으로 그 벽의 연장을 따른다(2026-08-29 좌우 통일). basinExtras: spec.hidden_pipe ? layout.basinExtras.filter((_wall, i) => spanCovers(tierSpanOf(link.source, `bextra${i}`), link.deltaM), ) : [], basinFill: spec.hidden_pipe && inletIn ? layout.basinFill : empty, basin: null, designTrim: keepTrimSides( layout.designTrim, inletIsMax ? outletIn : keepInlet, inletIsMax ? keepInlet : outletIn, ), }; } /** * 독립 기슭막이(관 숨김)의 **설치 측**(양쪽/좌/우)만 남긴다 — 배관은 유입·유출 두 쪽을 * 늘 세우지만 독립 기슭막이는 사용자가 고른 쪽만 세운다. 좌(+offset=좌측)=trimMax 쪽, * 우=trimMin 쪽. 반대쪽 벽·다단·성토부선·트림을 모두 지운다(링크 필터와 같은 규칙). */ export function restrictToSide(layout: CulvertLayout, side: string | undefined): CulvertLayout { if (side !== "좌" && side !== "우") return layout; // 양쪽·미지정 → 둘 다 const inletIsMax = layout.pipe.inlet.offset > layout.pipe.outlet.offset; const keepMax = side === "좌"; const keepMin = side === "우"; const keepInlet = inletIsMax ? keepMax : keepMin; const keepOutlet = inletIsMax ? keepMin : keepMax; const empty = { segments: [], addable: false }; return { ...layout, walls: layout.walls.filter((wall) => (wall.role === "inlet" ? keepInlet : keepOutlet)), extraWalls: keepOutlet ? layout.extraWalls : [], outletFill: keepOutlet ? layout.outletFill : empty, basinExtras: keepInlet ? layout.basinExtras : [], basinFill: keepInlet ? layout.basinFill : empty, basin: keepInlet ? layout.basin : null, designTrim: keepTrimSides(layout.designTrim, keepMin, keepMax), }; } export function computeCardCulvert( section: CrossSection, sourceSamples: SectionSample[], activeRevet: RevetKey | null, revetOffset?: RevetOffsetControl, inletStructure?: InletStructureControl, extraWalls?: ExtraWallControl, link?: CulvertLink, revetLink?: RevetLinkControl, ): CulvertLayout | null { // 링크 측점 — 소유 측점 **스펙**을 이 측점 지형 위에 세운다. 연동으로 물려받는 // 것은 횡단도 **위치 4축(x·d·h·m)** 뿐이고, 원지반과 만나는 정보(접합·성토부선· // 교차점)는 이 측점 것으로 정상 계산한다(2026-08-24 사용자 확정). if (!section.culvert && link) { const detached = !( revetLink?.linkedFor(section) ?? section.design?.revet_link_detached !== true ); // 조작값 원천 — 연동이면 소유 측점, 해제면 이 측점(4축 전부 개별). // 단 **구조물 형식**(기슭막이/집수정 I·ㄴ·ㄷ)은 언제나 소유 측점 값이다. 해제는 // "위치를 따로 잡는다"는 뜻이지 다른 구조물을 세운다는 뜻이 아니다 — 이 측점에서 // 자동 판정을 다시 돌리면 벽 2개가 집수정 1개로 바뀐다(2026-08-24 화면 실측). const hosted: CrossSection = { ...section, culvert: link.source.culvert }; // 연동이면 소유 측점이 **실제로 적용한 4축**(자동 자리 포함)을 명시값으로 물려준다. // 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시 // 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자). const ownerInput = adjustsInput(link.source, revetOffset, extraWalls); const ownerApplied = computeCulvertLayout( link.source, link.source.samples, ownerInput, link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), inletStructure?.adjustFor(link.source), )?.revetShift ?? ownerInput; // 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄 // 벽만 자기 값으로 선다(2026-08-30 사용자). const merged = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied; // 이 측점까지 오지 않는 단은 **계산에서부터** 뺀다. 다 세워 놓고 그림만 지우면 // (`trimLinkedLayout`) 성토부선은 원래 단 수로 계산된 채 남아, 마지막 단 뒤 성토면이 // 갱신되지 않는다(2026-08-30 사용자 지적: 우측 독립 기슭막이에서 단이 없어져도 // 그 위 성토면이 그대로). 단은 위에서부터 쌓이므로 **덮이는 앞부분만** 남긴다. const tierPrefix = (kind: "extra" | "bextra", total: number): number => { let count = 0; while (count < total && spanCovers(tierSpanOf(link.source, `${kind}${count}`), link.deltaM)) { count += 1; } return count; }; const ownerAdjusts: WallAdjustSet = merged && { ...merged, extras: merged.extras.slice(0, tierPrefix("extra", merged.extras.length)), basinExtras: merged.basinExtras.slice(0, tierPrefix("bextra", merged.basinExtras.length)), }; // 경사 해제 = **수평(Z 일원화)**. 3D 링만 눕히는 게 아니라 횡단도에서도 기준벽 // 상단 표고를 소유 측점과 같게 맞춘다(2026-08-30 사용자 확정 — 종전 "3D 전용" // 규정을 갈아엎는다). 차이는 노견 확장 구간이 흡수하므로 성토선은 이 측점 // 노견에서 그대로 출발한다(2026-08-24 "성토선이 노견에서 떨어진다" 지적 회피). const followGrade = revetLink?.followGradeFor(link.source) ?? link.source.design?.revet_follow_grade !== false; const layout = computeCulvertLayout( hosted, sourceSamples, followGrade ? ownerAdjusts : levelToOwner(ownerAdjusts, link.dz), link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), inletStructure?.adjustFor(detached ? section : link.source), ); if (!layout) return null; // 노견 위로는 못 올라간다(기하가 d를 0에서 자른다) — 조작 중인 벽만 이유를 알린다. if (!followGrade && (activeRevet === "inlet" || activeRevet === "outlet")) { const requested = levelToOwner(ownerAdjusts, link.dz); const applied = activeRevet === "inlet" ? layout.revetShift.inlet : layout.revetShift.outlet; const asked = activeRevet === "inlet" ? requested?.inlet : requested?.outlet; if (asked?.d != null && applied.d != null && Math.abs(asked.d - applied.d) > 0.05) { showToast(L("B06_Cross_Revet_Limit_Up"), "info"); } } // 종단경사 반영은 **3D 전용** 스위치다(2026-08-24 사용자 확인). 횡단도는 언제나 // 그 측점 계획고로 그린다 — 레이아웃 전체를 −dz로 밀면 벽뿐 아니라 노견에서 // 출발하는 성토선까지 같이 내려가 **성토선이 노견에서 떨어진다**(사용자 지적). // 3D 스윕에서만 프레임 dz를 0으로 눕힌다(B05_Profile_UI_Corridor_Structures). const linked = trimLinkedLayout(layout, link); return link.source.culvert?.hidden_pipe ? restrictToSide(linked, link.source.culvert.side) : linked; } const equalizeExtras = extraWalls?.consumeEqualize(section) ?? false; // 독립 기슭막이(관 숨김)는 유입측도 **집수정이 아니라 기슭막이 벽**으로 강제한다 — // 관이 없어 집수정 자동 전환이 의미 없다(설치 측은 아래 restrictToSide가 가린다). const layout = computeCulvertLayout( section, sourceSamples, adjustsInput(section, revetOffset, extraWalls), section.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(section), inletStructure?.adjustFor(section), equalizeExtras, ); if (!layout) return null; // I형 집수정 이동 한계 — 기하가 관 길이가 바뀌지 않는 자리로 잘라 돌려준다. // 잘렸으면 저장값을 실제 적용값으로 되돌리고(숫자만 커지는 것 방지) 이유를 알린다. if (inletStructure) { const requested = inletStructure.adjustFor(section); const applied = layout.basinAdjust; const moved = Math.abs(requested.lateralM - applied.lateralM) > 0.05 || Math.abs(requested.slopeM - applied.slopeM) > 0.05; if (moved) { if (activeRevet === "inlet") showToast(L("B06_Cross_Basin_Limit_Pipe"), "info"); inletStructure.updateAdjust(section.chainage_m, applied); } } if (extraWalls && extraWalls.countFor(section, "basin") > layout.basinExtras.length) { // 유입측(집수정 계류측·독립 기슭막이)도 유출측과 같은 규칙으로 알린다 — 종전에는 // 조용히 잘려서 +를 눌러도 아무 일이 없는 것처럼 보였다(2026-08-29 사용자 지적: // 우측 독립 기슭막이에서 추가2가 안 서는데 토스트가 없다). noticeExtraLimit(section.chainage_m, "basin", layout.basinExtras.length); extraWalls.syncCount(section.chainage_m, layout.basinExtras.length, "basin"); } else if (extraWalls) { clearExtraLimitNotice(section.chainage_m, "basin"); } if (extraWalls && extraWalls.countFor(section, "outlet") > layout.extraWalls.length) { // 요청 단 수보다 지형이 허락하는 단이 적다(벽이 원지반에 0.5m 이상 묻히면 성토 // 불필요 — 그 아래 단은 못 세운다). 조작 중일 때만 가능한 단 수를 토스트로 알린다. noticeExtraLimit(section.chainage_m, "outlet", layout.extraWalls.length); extraWalls.syncCount(section.chainage_m, layout.extraWalls.length, "outlet"); } else if (extraWalls) { clearExtraLimitNotice(section.chainage_m, "outlet"); } if (revetOffset) { const roles: Array<[RevetKey, WallAdjust]> = [ ["inlet", layout.revetShift.inlet], ["outlet", layout.revetShift.outlet], ...layout.revetShift.extras.map((applied, i): [RevetKey, WallAdjust] => [ `extra${i}`, applied, ]), ...layout.revetShift.basinExtras.map((applied, i): [RevetKey, WallAdjust] => [ `bextra${i}`, applied, ]), ]; const wallOf = (role: RevetKey) => role.startsWith("bextra") ? layout.basinExtras[Number(role.slice(6))] : role.startsWith("extra") ? layout.extraWalls[Number(role.slice(5))] : layout.walls.find((wall) => wall.role === role); for (const [role, applied] of roles) { const requested = revetOffset.adjustFor(section, role); // 축별로 요청이 잘렸으면 왜 안 됐는지 토스트로 알린다(조작 중인 벽만). if (activeRevet === role) { if (Math.abs(requested.x - applied.x) > 0.05) { showToast( L( requested.x < applied.x ? "B06_Cross_Revet_Limit_Inward" : "B06_Cross_Revet_Limit_Outward", ), "info", ); } if (requested.d != null && applied.d != null && Math.abs(requested.d - applied.d) > 0.05) { showToast( L(requested.d < applied.d ? "B06_Cross_Revet_Limit_Up" : "B06_Cross_Revet_Limit_Down"), "info", ); } // 높이가 재질 한계에 잘렸으면 재질 변경을 안내(2026-08-22 사용자 — 경고와 // 높이 해제는 재질 선택으로). const wall = wallOf(role); if (requested.h != null && applied.h != null && requested.h > applied.h + 0.05 && wall) { showToast( L("B06_Cross_Height_Limit") .replace("{mat}", materialLabel(wall.material)) .replace("{limit}", materialLimit(wall.material).toFixed(1)), "info", ); } if (requested.h != null && applied.h != null && requested.h < applied.h - 0.05) { showToast(L("B06_Cross_Height_Floor"), "info"); } } // d = null(기본 자리)은 null로 되돌려 담는다 — 적용 좌표를 그대로 저장하면 // 자동 자리가 "사용자 지정"으로 굳어 지형·높이 변경을 따라가지 못한다. revetOffset.syncApplied(section.chainage_m, role, { ...applied, d: requested.d == null ? null : applied.d, }); } } // 독립 기슭막이는 사용자가 고른 설치 측(양쪽/좌/우)만 남긴다. return section.culvert?.hidden_pipe ? restrictToSide(layout, section.culvert.side) : layout; } /** * **수평(Z 일원화) 보정**(2026-08-30 사용자 확정) — 경사를 풀면 기슭막이는 종단을 타지 * 않고 눕는다. 배관 벽 자리 규칙이 `상단 = 노견 − d/1.2`(`placePipeWall`)이므로 이 측점 * d를 `소유 d + 1.2 × dz`로 밀면 상단 표고가 소유 측점 값 그대로가 된다. 계획고 차 dz는 * **노견 확장 구간**이 흡수하고 성토선 각도(1:1.2)는 그대로다. * * 다단은 손대지 않는다 — 시작점이 기준벽 하단이라 기준벽을 따라 통째로 딸려 온다. * d가 음수로 내려가면(벽이 이 측점 노견보다 위) 기하가 0에서 자른다. */ function levelToOwner(adjusts: WallAdjustSet, dz: number): WallAdjustSet { if (!adjusts || Math.abs(dz) < 1e-9) return adjusts; const shift = (adjust: WallAdjust): WallAdjust => ({ ...adjust, d: (adjust.d ?? 0) + FILL_SLOPE_RATIO_MIN * dz, }); return { ...adjusts, inlet: shift(adjusts.inlet), outlet: shift(adjusts.outlet) }; } /** 한 측점의 4축 조작값 한 벌(기준벽 2 + 다단). */ type WallAdjustSet = | { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] } | undefined; /** * **연동을 푼 측점**의 4축(2026-08-30 사용자 확정: "연동 해제는 분리가 아니다"). * 단 수·형식은 소유 측점 것 그대로이고, 이 측점이 **손댄 벽만** 자기 값으로 바뀐다. * 종전에는 이 측점 값(대개 빈 값)으로 통째로 다시 풀어 다단이 통째로 사라졌다 * (2026-08-30 화면 실측: 벽 4 → 2). */ function detachedAdjusts( section: CrossSection, base: WallAdjustSet, revetOffset?: RevetOffsetControl, ): WallAdjustSet { if (!base) return base; const pick = (role: string, fallback: WallAdjust): WallAdjust => revetOffset?.storedAdjustFor(section, role as RevetKey) ?? fallback; return { inlet: pick("inlet", base.inlet), outlet: pick("outlet", base.outlet), extras: base.extras.map((adjust, i) => pick(`extra${i}`, adjust)), basinExtras: base.basinExtras.map((adjust, i) => pick(`bextra${i}`, adjust)), }; } function adjustsInput( section: CrossSection, revetOffset?: RevetOffsetControl, extraWalls?: ExtraWallControl, ): | { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] } | undefined { if (!revetOffset) return undefined; return { inlet: revetOffset.adjustFor(section, "inlet"), outlet: revetOffset.adjustFor(section, "outlet"), extras: Array.from({ length: extraWalls?.countFor(section, "outlet") ?? 0 }, (_, i) => revetOffset.adjustFor(section, `extra${i}`), ), basinExtras: Array.from({ length: extraWalls?.countFor(section, "basin") ?? 0 }, (_, i) => revetOffset.adjustFor(section, `bextra${i}`), ), }; } /** * **구조물 없는 상태**의 설계 절·성토선이 원지반과 만나는 지점이 들어오는 데 * 필요한 표시 반폭(m, 여유 0.5 포함) — 2026-08-22 사용자 확정 줌 기준. * 구조물(기슭막이·집수정)이 어디로 가든 화면 기준은 지형·설계선이 정한다. */ export function culvertRequiredHalfWidth(section: CrossSection): number | null { const design = section.design; if (!design) return null; const groundAt = groundInterpolator(section.samples); const designAt = designInterpolator(design.design_line); const edges = design.road_edges; if (!groundAt || !designAt || !edges) return null; const offsets = section.samples.map((sample) => sample.offset_m ?? 0); const limits = { left: Math.max(...offsets), right: Math.min(...offsets) }; let extent = 0; for (const side of ["left", "right"] as const) { const edge = side === "left" ? edges.left : edges.right; const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limits[side]); extent = Math.max(extent, Math.abs(toe)); } return extent > 0 ? extent + 0.5 : null; }