Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts
T
eomsangdonandClaude Opus 5 83eb58a251 feat(B05·B06): 배수관 측점 정본화 · 코리도 단차 제거 · 인접 측점 기슭막이 링크
3D 예상형상은 종단·횡단 확정 뒤의 최종 산출물이고 3D 쪽 편집 경로는 없다
(2026-08-24 사용자 확정). 그런데 구조물(비정규) 측점은 횡단 정본 자체가 없어
조회할 때마다 프리뷰 기본값이 재계산됐고, 조정창 조작값은 세션에서 사라졌다.
정본 경로를 열고, 그 위에서 코리도 단차를 걷어냈다.

① 구조물 측점 정본화 — 확정·임시저장이 종단 정본 stations 중 DB 행이 없는
   측점을 대상에 넣어 upsert. cross_sections 19 → 23행.
② 조정창 조작값 정본화 — 기슭막이 4축(x·d·h·m)·다단 단 수를 design.revet_adjust
   / extra_wall_counts로 저장하고, 3D culvertLayoutOf가 그 값을 기하 입력으로 쓴다.
   카드 우선순위는 세션 → 정본 → 자동.
③ 노폭 연장의 노견 편입 — PIECE_COLS.shoulder 2→3으로 원 노견 끝 꺾임을 남기고,
   조기 return을 없애 확폭이 구조물 구간 내내 유지되게 했다.
④ 종단 기울기 반영 — 구조물 구간 복사 단면에 계획고 차이를 전량 얹는다.
   노견 표고 단차 0.49~0.61m 제거.
⑤ 접속 테이퍼 — 구간 경계를 제어점으로 세워 "복사 vs 보간" 두 규칙을 하나로
   통합. 노폭 3.0m·성토비탈 8.1m 단차 제거. 접속 길이 = 인접 측점까지.
   제어점 생성은 B05_Profile_UI_Corridor_Frames.ts로 분리(Build 699 → 635줄).
⑥ 인접 측점 링크 — 기슭막이 연장(기본 10m)이 덮는 옆 측점 횡단도에도 소유 측점
   단면을 계획고 차이만 얹어 그린다. 요소별 연장 판정(관·집수정 제외), 표시 전용.

BUILD_VERSION 11 → 14 (저장 코리도 만료).
tsc/ruff/prettier 통과, pytest 192 passed (기존 실패 1건 유지).
작업이력·자체검증 기록: docs/raw/PLAN.md §4-8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:11:21 +09:00

336 lines
15 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 } from "./B06_Section_UI_Cross_Culvert_Types";
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;
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) — 기슭막이 연장·집수정 길이 중 큰 값. */
function culvertReach(section: CrossSection): { beforeM: number; afterM: number } | 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;
const length = spec.revet_length_m ?? 10;
const before = spec.revet_before_m ?? length / 2;
const after = spec.revet_after_m ?? Math.max(length - before, 0);
const basinHalf = (spec.basin_length_m ?? 0) / 2;
beforeM = Math.max(beforeM, before, basinHalf);
afterM = Math.max(afterM, after, basinHalf);
}
return beforeM > 0 || afterM > 0 ? { beforeM, afterM } : null;
}
/**
* 이 측점을 덮는 구조물 측점을 찾는다. 자기 구조물이 있으면 링크 아님(소유 측점).
* 여러 구조물이 겹치면 가장 가까운 측점 — 3D `structureAt`과 같은 규칙.
*/
export function culvertLinkFor(
section: CrossSection,
sections: readonly CrossSection[],
designZAt: (chainageM: number) => number | null,
): CulvertLink | undefined {
if (section.culvert) return undefined;
let best: CrossSection | null = null;
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;
}
}
if (!best) return undefined;
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,
};
}
/**
* 기하 전체를 종단 차이만큼 올려/내린다 — offset(횡방향)은 그대로다. 표고 필드는
* 이름이 `elevation`·`minElevation`·`maxElevation`처럼 끝나므로 키로 찾아 더한다.
*/
function shiftLayoutElevation<T>(value: T, dz: number): T {
if (Array.isArray(value))
return value.map((item) => shiftLayoutElevation(item, dz)) as unknown as T;
if (value && typeof value === "object") {
const clone: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
clone[key] =
typeof entry === "number" && /elevation$/i.test(key)
? entry + dz
: shiftLayoutElevation(entry, dz);
}
return clone as T;
}
return value;
}
export function computeCardCulvert(
section: CrossSection,
sourceSamples: SectionSample[],
activeRevet: RevetKey | null,
revetOffset?: RevetOffsetControl,
inletStructure?: InletStructureControl,
extraWalls?: ExtraWallControl,
link?: CulvertLink,
): CulvertLayout | null {
// 링크 측점 — 소유 측점 단면을 그대로 계산해 종단 차이만 얹는다. 한계 토스트·
// 조작값 되받기는 하지 않는다(소유 측점 카드가 이미 한다).
if (!section.culvert && link) {
const owner = computeCulvertLayout(
link.source,
link.source.samples,
adjustsInput(link.source, revetOffset, extraWalls),
inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
);
if (!owner) return null;
const shifted = shiftLayoutElevation(owner, link.dz);
// 집수정은 자기 길이(기본 2m)만큼만 이어진다 — 기슭막이 연장(10m)에 얹혀
// 따라오면 있지도 않은 자리에 집수정이 생긴다. 관도 마찬가지(한 측점 전용).
const basinHalf = (link.source.culvert?.inlet.basin_length_m ?? 0) / 2;
return link.distanceM > basinHalf + 1e-9 ? { ...shifted, basin: null } : shifted;
}
const equalizeExtras = extraWalls?.consumeEqualize(section) ?? false;
const layout = computeCulvertLayout(
section,
sourceSamples,
adjustsInput(section, revetOffset, extraWalls),
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) {
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 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;
}