Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts
T
eomsangdonandClaude Opus 5 cd855e32d3 feat(B06): 관 접속 절·성토선 3도 물매 + 교차점 보간 + 절성토 교차 기준 자동 줌아웃
- 관 유도 접속선을 0도 → **3도**로: 유입측은 계류 쪽 오름(물이 관으로),
  유출측 매몰 절토선은 계류 쪽 내림(물이 밖으로). 집수정 I형 되메움선도 동일
- 모든 유도선·성토부선 끝점을 격자점이 아니라 **선형 보간 교차점**에 붙여
  선이 원지반을 미세하게 벗어나던 문제 해소(2026-08-22 사용자 지적)
- 카드 자동 줌아웃: **구조물 없는 상태**의 설계 절·성토선이 원지반과 만나는
  지점(+0.5m)이 표시 반폭 밖이면 그 카드만 반폭을 넓혀 그린다. 보유 샘플
  범위를 넘는 경우는 기존 전역 반폭 재생성 경로(applyPanelToAll)가 담당

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

185 lines
8.5 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 { materialLabel, materialLimit } 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-22 사용자). 세션 보관은 Page가 한다. */
export interface InletStructureControl {
valueFor: (section: CrossSection) => InletStructureChoice;
set: (chainageM: number, value: InletStructureChoice) => void;
}
/** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */
export interface ExtraWallControl {
countFor: (section: CrossSection) => number;
/** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */
setCount: (chainageM: number, count: number) => void;
/** 다단 등간격 배치 요청(2026-08-22 ①) — 다음 계산 1회에 적용된다. */
equalize: (chainageM: number) => void;
/** 등간격 요청을 소비(1회성) — 계산 직전에 Wire가 부른다. */
consumeEqualize: (section: CrossSection) => boolean;
/** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */
syncCount: (chainageM: number, built: number) => 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처럼 자동
* 자리가 이미 안쪽 한계라 ◀가 그대로 먹히지 않는 경우). 조작 중(그 벽이 선택된
* 상태)일 때만 띄운다 — 리로드로 복원된 옛 세션 값에는 침묵.
*/
export function computeCardCulvert(
section: CrossSection,
sourceSamples: SectionSample[],
activeRevet: RevetKey | null,
revetOffset?: RevetOffsetControl,
inletStructure?: InletStructureControl,
extraWalls?: ExtraWallControl,
): CulvertLayout | null {
const equalizeExtras = extraWalls?.consumeEqualize(section) ?? false;
const layout = computeCulvertLayout(
section,
sourceSamples,
adjustsInput(section, revetOffset, extraWalls),
inletStructure?.valueFor(section),
equalizeExtras,
);
if (!layout) return null;
if (extraWalls && extraWalls.countFor(section) > 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);
}
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,
]),
];
const wallOf = (role: RevetKey) =>
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 (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");
}
}
revetOffset.syncApplied(section.chainage_m, role, applied);
}
}
return layout;
}
function adjustsInput(
section: CrossSection,
revetOffset?: RevetOffsetControl,
extraWalls?: ExtraWallControl,
): { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[] } | undefined {
if (!revetOffset) return undefined;
return {
inlet: revetOffset.adjustFor(section, "inlet"),
outlet: revetOffset.adjustFor(section, "outlet"),
extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) =>
revetOffset.adjustFor(section, `extra${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;
}