「구조물을 놓아도 횡단도에 아무것도 안 보인다」가 창 둘에서 같은 증상이었다. 화면에 표식을 심어 재 보니 **브라우저 쪽 단면에 `revetment` 자체가 없었다**. - 원인: `withDraftWalls` 가 미저장 초안 목록이 **빈 배열**일 때도 「초안 있음」으로 읽고 **서버 저장분(`revetment`)을 통째로 지운 뒤** 아무것도 안 얹었다. ⇒ `!drafts || !drafts.length` 로 고쳤다 — 빈 목록도 「초안 없음」이다. - 초안 경로가 `foundation` 을 안 실어 터파기가 안 그려지던 것도 함께 고침 (`WallSpec.foundation` 추가, 거울 시험도 같이 갱신). - 겹침 방지 가드를 **좁혔다** — 링크가 있기만 하면 막던 것을, 그 링크의 벽이 **이 카드에 실제로 설 때만** 막게 했다(`culvertWallsStandAt`). 관이 아홉·열하나인 노선에서는 링크가 거의 모든 측점을 덮어 독립 벽 경로가 통째로 죽어 있었다. 화면 확인: 벽 터파기 「기초유 · 폭 1.62m · 깊이 0.50m (수직)」, 관 터파기 Φ1000 1.80m · Φ800 1.60m, 그려진 선 전부 좌우 변이 수직. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
682 lines
35 KiB
TypeScript
682 lines
35 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,
|
||
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)는 소유 벽 제원조차 없을 때의 마지막 값이다.
|
||
* 손으로 한 번 잡은 단은 그 값으로 고정된다 — 계곡부·능선부에서 단마다 연장이 다르다.
|
||
*/
|
||
/** 「단이 못 섰다」 안내를 이미 낸 자리 — 다시 그릴 때마다 뜨지 않게 한 번만 낸다.
|
||
* 키에 세워진 단 수를 넣어, 지형·조작이 바뀌어 결과가 달라지면 다시 알린다. */
|
||
const extraLimitNotified = new Set<string>();
|
||
|
||
/** 요청 단 수보다 적게 섰음을 알린다 — 조정창을 열지 않은 경로(좌측 폼 등)에서도 뜬다.
|
||
* 종전에는 `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;
|
||
}
|