Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts
T
eomsangdonandClaude Opus 5 0d4e4c758d feat(B06,B05): 기슭막이 연동·경사를 재정의하고 링크 카드까지 강조한다
사용자 확정(2026-08-30) 세 가지를 반영한다.

① 링크 강조 — 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다. 선택
(조정창)과 강조를 분리해 `highlightFor`/`applyRevetHighlight`로 카드 재생성 없이
클래스만 갈아 끼운다(줌·팬 보존). 다단은 단 번호까지 같은 키로 짝짓는다.

② 연동 = 분리가 아니다 — 길이·전/후로 이미 하나의 기슭막이다. 연동을 푼 측점은
그 자리에 서므로 3D에서 벽이 노선과 평행이 아니라 자기 경로선을 따라간다. 링마다
폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 해제 시 단 수가
0이 되던 것(벽 4→2)도 고쳤다 — 소유 측점 4축에서 출발해 손댄 벽만 갈아 끼운다.

③ 경사 = 수평(Z 일원화) — 3D 링만 눕히던 것을 횡단도까지 넓혔다. 링크 카드 벽
상단 표고를 소유 측점과 맞추고(d = 소유 d + 1.2×dz) 차이는 노견 확장이 흡수한다.
그래서 수평 중에는 연동 해제 불가 — 버튼을 잠그고, 해제해 둔 값은 지운다.

곁다리: 3D 재빌드 해시에 extra_spans·revet_link_detached 추가(안 넣으면 저장
코리도가 안 만료된다), 캐시 저장 payload에 extra_spans 누락 보완, 3D 머리 주석을
"정본만 읽는다" → "캐시를 읽는다"로 정정.

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

629 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<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`)이 있으면 그것,
* **없으면 소유 벽 연장을 상속**한다(2026-08-30 사용자: 손대기 전에는 기준벽과 같이
* 옆 측점에 서야 한다). 고정 기본 10m(5/5)는 소유 벽 제원조차 없을 때의 마지막 값이다.
* 손으로 한 번 잡은 단은 그 값으로 고정된다 — 계곡부·능선부에서 단마다 연장이 다르다.
*/
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);
}
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 ownerAdjusts = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied;
// 경사 해제 = **수평(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가 안 서는데 토스트가 없다).
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;
}
/**
* **수평(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;
}