Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts
T
eomsangdonandClaude Opus 5 1e55322f2f feat(B06): 다단 기슭막이 구간값을 단별로 손수 잡는다
다단은 소유 벽 연장에 종속이라 길이·전/후를 따로 못 잡았다. 계곡부·능선부에서는
아래 단일수록 연장이 달라져 자동 규칙으로 못 잡으므로 수동 입력으로 간다.
조정창 UI는 기준벽 것을 그대로 쓴다(길이·기준측점 전·후 세 행).

- Const: EXTRA_SPAN_DEFAULT(10m·5/5) + applySpanPatch()로 길이↔전/후 산식을
  기준벽과 공용화
- Station_Controls: StructureSpanControl에 tierValuesFor/updateTier 추가. 값은
  세션(b06:extraspan)에 담고 캐시(design.extra_spans)에도 얹는다. 단 수를 줄이면
  그 단의 구간값도 지운다
- View_Structure: 다단 키면 단별 제어기로, 기준벽은 종전 경로
- Wire: culvertReach에 단별 연장을 더하고, 링크 카드에서 각 단을 자기 구간값으로
  판정한다(tierSpanOf)
- Corridor_Structures: 3D 스윕 범위를 단별 구간값으로
- 저장: CrossSectionPatch.extra_spans(프론트 payload + WallSpanPatch 스키마 +
  확정 병합) — [저장]·[확정] 때 정본에 남는다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 10:54:03 +09:00

534 lines
26 KiB
TypeScript

/* =============================================================================
* 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,
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;
selectedFor: (section: CrossSection) => RevetKey | null;
select: (chainageM: number, key: RevetKey | null) => void;
/** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */
syncApplied: (chainageM: number, role: RevetKey, applied: WallAdjust) => void;
/** 일부 축만 바꾼다 — 나머지는 유지. h: null = 기본값 복귀. */
update: (chainageM: number, role: RevetKey, patch: Partial<WallAdjust>) => 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<SpanValues>) => void;
/** 다단 한 단의 구간값 — 기준벽과 달리 **단마다 따로** 잡는다(2026-08-29 사용자).
* 값은 세션에 담기고 [저장]·[확정] 때 정본으로 간다. 없으면 기본 10m(5/5). */
tierValuesFor: (section: CrossSection, key: RevetKey) => SpanValues;
updateTier: (section: CrossSection, key: RevetKey, patch: Partial<SpanValues>) => 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<BasinAdjust>) => 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`)이 있으면 그것,
* 없으면 고정 기본 10m(5/5). 기준벽 연장을 상속하지 않는다(2026-08-29 사용자).
*/
export function tierSpanOf(section: CrossSection, key: string): StructureSpan {
const stored = section.design?.extra_spans?.[key];
if (!stored) return { ...EXTRA_SPAN_DEFAULT };
return { beforeM: Math.max(stored.before_m, 0), afterM: Math.max(stored.after_m, 0) };
}
/** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이·집수정·다단 중 큰 값. */
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;
}
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),
),
outletFill: spanCovers(tierSpanOf(link.source, "extra0"), link.deltaM)
? 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 && spanCovers(tierSpanOf(link.source, "bextra0"), link.deltaM)
? 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 ownerAdjusts = detached
? adjustsInput(section, revetOffset, extraWalls)
: (computeCulvertLayout(
link.source,
link.source.samples,
adjustsInput(link.source, revetOffset, extraWalls),
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
)?.revetShift ?? adjustsInput(link.source, revetOffset, extraWalls));
const layout = computeCulvertLayout(
hosted,
sourceSamples,
ownerAdjusts,
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(detached ? section : link.source),
);
if (!layout) return null;
// 종단경사 반영은 **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가 안 서는데 토스트가 없다).
if (activeRevet === "inlet" || activeRevet?.startsWith("bextra")) {
showToast(
L("B06_Cross_Extra_Limit").replace("{n}", String(layout.basinExtras.length)),
"info",
);
}
extraWalls.syncCount(section.chainage_m, layout.basinExtras.length, "basin");
}
if (extraWalls && extraWalls.countFor(section, "outlet") > layout.extraWalls.length) {
// 요청 단 수보다 지형이 허락하는 단이 적다(벽이 원지반에 0.5m 이상 묻히면 성토
// 불필요 — 그 아래 단은 못 세운다). 조작 중일 때만 가능한 단 수를 토스트로 알린다.
if (activeRevet === "outlet" || activeRevet?.startsWith("extra")) {
showToast(
L("B06_Cross_Extra_Limit").replace("{n}", String(layout.extraWalls.length)),
"info",
);
}
extraWalls.syncCount(section.chainage_m, layout.extraWalls.length, "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;
}
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;
}