Files
Aislo/B06_Section/B06_Section_UI_Cross_Revetment.ts
T
eomsangdonandClaude Opus 5 023c0d545a fix(B06): 독립 기슭막이에도 좌우 이동 안내 + 하한 알림 조건 좁힘
- 독립 기슭막이(D군) 툴팁은 **따로 만들어져** 있어 「지형에 막힘」조차 안 떴음.
  배관 벽과 같은 두 줄(지형 막힘 · 각도 하한)을 같은 문구로 붙임.
- 기준벽 경로(`computeRevetmentLayout`)에도 `shiftFloorM` 을 남김 —
  하한이 `1.2 × 상하 내림 − 자동 자리`라 d 2.6m 자리에서 1.0m 요청이 삼켜졌음.
- 하한 알림은 **사용자가 실제로 값을 넣었을 때만** 뜨게 좁힘. 요청이 0이면
  하한은 그냥 기본 자리라 모든 벽에 뜨면 소리만 됨.
- 문구를 두 경로에 맞게 고침 — 「상하 내림의 1.2배」가 아니라
  「성토선 각도(1:1.2)를 지키려면 그만큼은 나가 있어야 함」.
- 시험 3건(`test_b06_extra_shift_floor.py`) — 하한 적용·알림·요청 없을 때 침묵.

미확인 — 실화면에서 알림이 뜨는 것까지는 못 봄. 지금 프로젝트(용화)의 카드가
전부 링크 카드라 좌우 이동 조작 자체가 먹지 않아 조건을 세우지 못했음.

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

408 lines
18 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_Revetment.ts
* 독립 기슭막이(배관과 무관) — 배관 세트 기슭막이와 **같은 배치·같은 산식·같은 도형**.
* 배관만 없을 뿐, 벽 자리·이동·한계는 배관 유출 벽과 한 몸으로 푼다(2026-08-28 사용자:
* "배관용에서 배관만 빼면 대부분 동일해야").
*
* · 자리(2026-08-29 사용자 개정): 조작은 **순수 좌우(x)·순수 상하(d)** 0.1m다.
* x = 자동 자리에서의 수평 이동, d = 노견에서 잰 수직 하강. 성토선 각도는 유지하고
* **노견 확장 구간**이 길이를 흡수한다. 한계는 노견 안쪽 금지·확장 0(각도 유지)·매몰 금지.
* 배관 기준벽(`placePipeWall`)의 이동은 손대지 않는다.
* · 실제 적용값(한계 절삭 후)은 `appliedAdjust`로 돌려주고, 화면이 조작 저장소에 되받는다
* — 눌러도 안 움직이는데 숫자만 커지는 것 방지(배관과 같은 syncApplied 규칙).
* · 다단: 배관과 **같은 함수** `buildExtrasAt`. 그리기(폴리곤·이음선·돌·성토부선)는 공용
* `_Cross_Wall`. 노견→벽 성토선은 designTrim의 slope로 그린다.
*
* 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
FILL_SLOPE_RATIO_MIN,
PIPE_CONNECT_GRADE,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
import {
ZERO_ADJUST,
type CulvertDesignTrim,
type OutletFillSegment,
type WallAdjust,
type WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { appendPlanLine, buildRevetWallGeometry, drawRevetWall } from "./B06_Section_UI_Cross_Wall";
const SVG_NS = "http://www.w3.org/2000/svg";
/** 서버가 얹어 주는 독립 기슭막이 제원(`section.revetment`, 구조물 정본 D군). */
export interface RevetmentSpec {
structure_id: string | null;
type_id: string;
name: string;
start_m: number;
end_m: number;
anchor_m: number;
form?: string | null;
height_m?: number | null;
/** 사용자가 고른 설치 측 — "좌" | "우". */
side?: string | null;
/** 단 수(다단). 1이면 단일 벽. */
tiers?: number | null;
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
lift_m?: number | null;
shift_m?: number | null;
}
/** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */
export interface RevetmentAdjust {
x?: number;
d?: number | null;
h?: number | null;
}
export interface RevetPoint {
offset: number;
elevation: number;
}
/** 한 단(段) — 3D 스윕용 폴리곤(배관 벽과 같은 합성 단면). */
export interface RevetmentTier {
polygon: RevetPoint[];
}
export interface RevetmentLayout {
side: "left" | "right";
/** 1단 벽 높이(m) — **바닥~상단**. 조정창 높이 표시·조작의 기준값. */
heightM: number;
/** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 센다(재렌더 중복 방지). */
chainageM: number;
/** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */
requestedTiers: number;
/** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */
top: RevetPoint;
/** 벽 목록(1단 + 다단) — 배관 벽과 같은 `WallLayout`, 그리기가 그대로 쓴다. */
walls: WallLayout[];
/** 벽 사이·벽 아래 성토부선(배관 다단과 같은 체계). */
fillSegments: OutletFillSegment[];
/** 성토 설계선을 벽 상단에서 끊고 노견→벽 성토선을 대신 그리는 트림. */
designTrim: CulvertDesignTrim;
/** 한계 절삭 후 **실제 적용된** 1단 조작값 — 화면이 조작 저장소에 되받는다. */
appliedAdjust: WallAdjust;
/** 3D 스윕용 단 목록 — 벽 폴리곤 그대로. */
tiers: RevetmentTier[];
/** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */
span: { beforeM: number; afterM: number };
}
/** 형태(구조물 옵션) → 재질(높이 한계·라벨). 메 계열은 dry, 찰=wet, 콘크리트=concrete. */
function materialOfForm(form?: string | null): RevetMaterial {
if (!form) return "dry";
if (form.includes("찰")) return "wet";
if (form.includes("콘크리트")) return "concrete";
return "dry"; // 메쌓기·돌망태·통나무·바자 — 메 계열(2.0m 한계)
}
/** 유효 지반 샘플의 바깥 한계 offset(설치 측). 없으면 null. */
function sampleLimit(section: CrossSection, outward: number): number | null {
const offsets = section.samples
.filter((s) => s.valid !== false && typeof s.offset_m === "number")
.map((s) => s.offset_m as number);
if (!offsets.length) return null;
return outward > 0 ? Math.max(...offsets) : Math.min(...offsets);
}
/**
* 노면 횡단물매를 **바깥 방향 1m당 표고 변화**로 돌려준다(하향이면 음수).
* 노면은 측구 쪽으로 내려가는 단일 평면이라(엔진 `road_z`), 노견을 밖으로 연장하면
* 그 평면을 그대로 타고 가야 각도가 맞는다(2026-08-29 사용자).
* 1순위는 **실제 그려진 노견 구간**(차도 끝 → 노면 끝)의 기울기 — 부호를 추측하지
* 않는다. 노견 폭이 0이면 `cross_slope_pct`를 측구 방향 부호로 쓴다.
*/
function roadSlopePerOutward(
design: NonNullable<CrossSection["design"]>,
side: "left" | "right",
outward: number,
): number {
const edge = design.road_edges?.[side];
const inner = design.carriageway_edges?.[side];
if (edge && inner) {
const run = (edge.offset_m - inner.offset_m) * outward;
if (run > 1e-6) return (edge.elevation_m - inner.elevation_m) / run;
}
const pct = Number(design.cross_slope_pct);
if (!Number.isFinite(pct)) return 0;
return (pct / 100) * (design.ditch_side === side ? -1 : 1);
}
/**
* 독립 기슭막이 단면 — 제원·높이·설계선이 없거나 자리를 못 찾으면 null.
* 1단 벽 = 배관 유출 벽과 같은 `placePipeWall`+`buildRevetWallGeometry`,
* 다단 = 배관과 같은 `buildExtrasAt`.
*/
export function computeRevetmentLayout(
section: CrossSection,
adjust?: RevetmentAdjust | null,
): RevetmentLayout | null {
const spec = section.revetment;
const design = section.design;
if (!spec || !design) return null;
const requestedHeight = Number(adjust?.h ?? spec.height_m);
if (!Number.isFinite(requestedHeight) || requestedHeight <= 0) return null;
// 설치 측이 비어 있으면 **성토가 나는 쪽**에 세운다 — 좌측 「구조물 배치」의 C군 벽
// (옹벽·돌쌓기 등)에는 설치 측 칸이 없기 때문이다(2026-09-06). 양측 절토면 벽이 설
// 자리가 없으므로 그리지 않는다(면적도 그대로).
const fillSide = (): "left" | "right" | null => {
if (design.section_mode === "left_cut") return "right";
if (design.section_mode === "right_cut") return "left";
if (design.section_mode === "both_fill") return "left";
return null;
};
const side = spec.side === "우" ? "right" : spec.side === "좌" ? "left" : fillSide();
if (!side) return null;
const outward = side === "left" ? 1 : -1;
const edge = design.road_edges?.[side];
const groundAt = groundInterpolator(section.samples);
const limit = sampleLimit(section, outward);
if (!edge || !groundAt || limit === null) return null;
// 높이 기준 = **벽 바닥~상단**(근입 0.5 포함 — 배관·추가 벽과 같은 기준,
// 2026-08-29 사용자). 재질(형태) 한계도 이 기준으로 자른다.
const pureHeight = Math.min(
Math.max(requestedHeight, REVET_EMBED_DEPTH_M + 0.1),
revetHeightLimit(spec.form),
);
/** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */
const height = pureHeight - REVET_EMBED_DEPTH_M;
const roadSlope = roadSlopePerOutward(design, side, outward);
const autoOffset =
edge.offset_m + outward * (fillWallBaseWidth(height) / 2 - REVET_THICKNESS_M / 2);
// 자동 자리 = 성토사면(1:1.2) 위 기본 지점 — 배관 유출 벽과 같은 기준값.
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
/**
* 조작 축(2026-08-29 사용자): x = **순수 좌우(m)**, d = **노견에서 잰 순수 수직
* 하강(m)**. 성토선 각도는 그대로 두고 **노견 확장 구간**이 길이를 흡수한다
* (확장량 t는 아래 폴리라인에서 역산 — t ≥ 0이 각도 유지의 한계다).
*/
const defaultDrop = autoRun / FILL_SLOPE_RATIO_MIN;
/** 그 자리에 벽을 세울 수 있나 — 매몰(원지반 아래 daylight 없음) 금지. */
const placeable = (xShift: number, drop: number): boolean => {
const anchor = autoOffset + outward * (autoRun + xShift);
const invert = edge.elevation_m - drop - height;
return (
invert >= groundAt(anchor) - 0.01 ||
slopedCrossing(
{ offset: anchor, elevation: invert },
outward,
-PIPE_CONNECT_GRADE,
groundAt,
limit,
) !== null
);
};
const requestedX = Math.max(adjust?.x ?? 0, 0);
/** 각도(1:1.2)를 지키려면 그만큼은 밖으로 나가 있어야 하는 최소 수평 자리. */
const xFloor = (drop: number): number => Math.max(0, FILL_SLOPE_RATIO_MIN * drop - autoRun);
let appliedDrop = Math.max(adjust?.d ?? defaultDrop, 0);
let appliedX = Math.max(requestedX, xFloor(appliedDrop));
for (let pass = 0; pass < 400 && !placeable(appliedX, appliedDrop); pass += 1) {
// 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
if (appliedDrop > 1e-9) {
appliedDrop = Math.max(0, appliedDrop - 0.05);
appliedX = Math.max(Math.min(appliedX, requestedX), xFloor(appliedDrop));
} else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05);
else break;
}
appliedX = Math.round(appliedX * 10) / 10;
appliedDrop = Math.round(appliedDrop * 10) / 10;
const anchorOffset = autoOffset + outward * (autoRun + appliedX);
const baseElevation = edge.elevation_m - appliedDrop - height;
const floatGapM = Math.max(0, baseElevation - groundAt(anchorOffset));
const material = materialOfForm(spec.form);
const lengthM = Number.isFinite(spec.end_m - spec.start_m) ? spec.end_m - spec.start_m : null;
const tier1 = buildRevetWallGeometry({
anchor: { offset: anchorOffset, elevation: baseElevation },
outward,
height,
material,
role: "outlet",
form: spec.form,
lengthM,
floatGapM,
});
// 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07).
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고,
// 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0 이어서 까닭을 알 길이 없었다.
const floorNow = xFloor(appliedDrop);
if (requestedX > 0.005 && floorNow > requestedX + 0.005) {
tier1.shiftFloorM = Math.round(floorNow * 100) / 100;
}
// 다단 — 배관과 같은 함수. 1단 벽 전면 하단 꼭짓점을 성토부선 시작점으로.
const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1));
const extras =
requestedTiers > 1
? buildExtrasAt(tier1.bottomFront, {
startBottomElevation: tier1.bottomBack.elevation,
outward,
groundAt,
limitOffset: limit,
adjusts: Array.from({ length: requestedTiers - 1 }, () => ({ ...ZERO_ADJUST })),
// 단들도 1단과 같은 형태로 선다 — 안 넘기면 형태가 비어 늘 메쌓기로
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
ownerForm: spec.form,
equalize: false,
})
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
const walls: WallLayout[] = [tier1, ...extras.walls];
const jt = tier1.topJoint;
// 노견 → (물매를 탄 노견 연장) → 벽 이음선 상단점. 연장 구간은 노면 물매 그대로 가고,
// 꺾임점에서 성토 물매 1:1.2로 벽 상단에 닿는다(2026-08-29 사용자 확정).
// D = 노견에서 벽 상단까지 바깥 방향 거리, R0 = 노견과 벽 상단의 낙차
// (D t) = 1.2 × (R0 + roadSlope × t) → t = (D 1.2·R0) / (1 + 1.2·roadSlope)
const edgePoint = { offset: edge.offset_m, elevation: edge.elevation_m };
const D = (jt.offset - edge.offset_m) * outward;
const R0 = edge.elevation_m - jt.elevation;
const denominator = 1 + FILL_SLOPE_RATIO_MIN * roadSlope;
const breakRun =
R0 > 1e-6 && D > 1e-6 && Math.abs(denominator) > 1e-9
? Math.min(D, Math.max(0, (D - FILL_SLOPE_RATIO_MIN * R0) / denominator))
: 0;
const slope = {
points:
breakRun > 1e-6
? [
edgePoint,
{
offset: edge.offset_m + outward * breakRun,
elevation: edge.elevation_m + roadSlope * breakRun,
},
jt,
]
: [edgePoint, jt],
};
const designTrim: CulvertDesignTrim =
outward > 0
? { minOffset: -Infinity, maxOffset: jt.offset, maxElevation: jt.elevation, maxSlope: slope }
: { minOffset: jt.offset, maxOffset: Infinity, minElevation: jt.elevation, minSlope: slope };
return {
side,
heightM: pureHeight,
chainageM: Number(section.chainage_m) || 0,
requestedTiers,
top: jt,
walls,
fillSegments: extras.segments,
designTrim,
appliedAdjust: { x: appliedX, d: appliedDrop, h: adjust?.h ?? null, m: null },
tiers: walls.map((wall) => ({ polygon: wall.points })),
span: {
beforeM: Math.max(spec.anchor_m - spec.start_m, 0),
afterM: Math.max(spec.end_m - spec.anchor_m, 0),
},
};
}
/** 자리가 없어 못 세운 단이 있으면 **한 번만** 알린다 — 카드마다 띄우면 화면이 덮인다. */
const shortfallStations = new Set<number>();
let shortfallTimer = 0;
let shortfallLast = "";
function noticeShortfall(layout: RevetmentLayout): void {
if (layout.walls.length >= layout.requestedTiers) return;
shortfallStations.add(Math.round(layout.chainageM * 100));
window.clearTimeout(shortfallTimer);
shortfallTimer = window.setTimeout(() => {
const message =
`기슭막이 ${layout.requestedTiers}단 중 ${layout.walls.length}단만 세울 수 있습니다` +
`(측점 ${shortfallStations.size}곳) — 아래 자리가 원지반에 묻힙니다. ` +
"기준 벽을 사면 위로 올린 뒤(▲) 단을 늘리세요.";
shortfallStations.clear();
if (message === shortfallLast) return;
shortfallLast = message;
showToast(message, "warning");
}, 400);
}
/** 횡단 카드에 독립 기슭막이(1단+다단)를 그린다 — 배관 벽과 같은 도형. 그렸으면 setter. */
export function appendRevetmentOverlay(
svg: SVGElement,
layout: RevetmentLayout | null,
x: (offset: number) => number,
y: (elevation: number) => number,
onSelect?: () => void,
): ((active: boolean) => void) | null {
if (!layout || !layout.walls.length) return null;
noticeShortfall(layout);
// 성토부선(벽 사이·벽 아래) 먼저 — 벽 밑에 깔린다.
for (const segment of layout.fillSegments) {
if (segment.kind === "cut") continue;
appendPlanLine(
svg,
segment.points,
x,
y,
`독립 기슭막이 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
(segment.overLimit ? " (법정 5m 이상 — 단 추가 검토)" : " (법정 5m 이내)") +
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
);
}
const drawn: SVGPolygonElement[] = [];
layout.walls.forEach((wall, index) => {
const keyId = index === 0 ? "own" : `own-extra${index - 1}`;
const tooltip =
`독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` +
`(상단 = 성토선 접점, 전면 1:${REVET_LEAN_RATIO}, 높이 한계 ${revetHeightLimit(
wall.form,
).toFixed(1)}m — 교본 7-3)` +
(wall.floatGapM > 0.01
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도`
: ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m`) +
(wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "") +
// 「좌우로 옮겨도 안 움직인다」의 까닭 두 가지 — 배관 벽 툴팁과 같은 문구다
// (2026-09-07). 독립 벽 툴팁은 따로 만들어져 있어 여태 어느 쪽도 안 떴다.
(wall.shiftBlockedM
? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)`
: "") +
(wall.shiftFloorM
? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` +
` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임`
: "");
const shape = drawRevetWall(svg, wall, x, y, "b06-chart__culvert-revet", tooltip, keyId);
if (onSelect) shape.classList.add("is-selectable");
drawn.push(shape);
});
// 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를
// 달면 가운데 클릭이 돌에 먹힌다(배관 벽과 같은 규칙 — 판정면만 위로).
if (onSelect) {
for (const wall of layout.walls) {
const hit = document.createElementNS(SVG_NS, "polygon");
hit.setAttribute(
"points",
wall.points.map((p) => `${x(p.offset)},${y(p.elevation)}`).join(" "),
);
hit.setAttribute("class", "b06-chart__culvert-revet-hit");
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelect();
});
svg.append(hit);
}
}
return (active: boolean): void => {
for (const polygon of drawn) polygon.classList.toggle("is-active", active);
};
}