|
|
|
@@ -10,6 +10,7 @@
|
|
|
|
|
* 단면 기하를 두 벌 만들지 않는다.
|
|
|
|
|
* ========================================================================== */
|
|
|
|
|
|
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
|
|
|
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
|
|
|
|
import {
|
|
|
|
|
REVET_EMBED_DEPTH_M,
|
|
|
|
@@ -33,6 +34,10 @@ export interface RevetmentSpec {
|
|
|
|
|
side?: string | null;
|
|
|
|
|
/** 단 수(다단). 1이면 단일 벽. */
|
|
|
|
|
tiers?: number | null;
|
|
|
|
|
/** 1단(기준·맨 위) 벽을 성토면 끝에서 도로 쪽으로 올린 사면 거리(m). 0이면 성토면 끝. */
|
|
|
|
|
lift_m?: number | null;
|
|
|
|
|
/** 기준 벽 좌우 이동(m) — + = 계류 쪽(바깥). 0이면 이동 없음. */
|
|
|
|
|
shift_m?: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RevetPoint {
|
|
|
|
@@ -40,7 +45,7 @@ export interface RevetPoint {
|
|
|
|
|
elevation: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 한 단(段) — 벽 하나와, 그 아래 다음 단으로 내려가는 성토선. */
|
|
|
|
|
/** 한 단(段) — 벽 하나. 1단이 기준(맨 위)이고 추가 단은 아래로 붙는다. */
|
|
|
|
|
export interface RevetmentTier {
|
|
|
|
|
/** 벽 상단 자리(배면 = 도로측). 1단은 성토면 끝. */
|
|
|
|
|
top: RevetPoint;
|
|
|
|
@@ -52,6 +57,10 @@ export interface RevetmentTier {
|
|
|
|
|
|
|
|
|
|
export interface RevetmentLayout {
|
|
|
|
|
side: "left" | "right";
|
|
|
|
|
/** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 세는 데 쓴다(재렌더 중복 방지). */
|
|
|
|
|
chainageM: number;
|
|
|
|
|
/** 사용자가 요청한 단 수 — 실제로 선 단 수(`tiers.length`)와 다르면 자리가 부족한 것이다. */
|
|
|
|
|
requestedTiers: number;
|
|
|
|
|
/** 1단 벽 상단 = 성토면 끝(설계선-지반 교차점). */
|
|
|
|
|
top: RevetPoint;
|
|
|
|
|
/** 단 목록 — 사용자가 지정한 단 수만큼, 벽이 지반에 묻히면 거기서 끝난다. */
|
|
|
|
@@ -123,34 +132,67 @@ function fillToeAt(section: CrossSection, side: "left" | "right"): RevetPoint |
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 다단 자리 — **성토선 하단(성토면 끝)이 1단**이고, 단을 늘리면 성토 사면을 따라 위로
|
|
|
|
|
* 올라가며 그 아래에 단이 들어온다(2026-08-28 사용자 확정: 배관 세트와 다른 점은
|
|
|
|
|
* ① 관이 없고 ② 시작 자리가 성토선 하단이라는 것).
|
|
|
|
|
*
|
|
|
|
|
* 자리는 설계 성토면(설계선) 위를 성토끝 → 노견 끝 방향으로 n등분해 잡는다 — 사면을
|
|
|
|
|
* 같은 길이로 나누는 것이 다단의 목적이고, 새 수치를 만들지 않는다.
|
|
|
|
|
* 추가 단 자리 — 기준에서 **설계 성토면을 따라 아래로** 벽 높이만큼 내려간 지점.
|
|
|
|
|
* 계단처럼 한 단씩 내려가며, 성토면 끝(toe)을 지나면 더 놓을 자리가 없다
|
|
|
|
|
* (그 부족분은 화면이 토스트로 알린다 — 2026-08-28 사용자 확정).
|
|
|
|
|
*/
|
|
|
|
|
function tierAnchors(
|
|
|
|
|
section: CrossSection,
|
|
|
|
|
side: "left" | "right",
|
|
|
|
|
anchor: RevetPoint,
|
|
|
|
|
toe: RevetPoint,
|
|
|
|
|
height: number,
|
|
|
|
|
count: number,
|
|
|
|
|
): RevetPoint[] {
|
|
|
|
|
const design = section.design;
|
|
|
|
|
const edge = design?.road_edges?.[side];
|
|
|
|
|
if (!edge || count <= 1) return [toe];
|
|
|
|
|
const anchors: RevetPoint[] = [];
|
|
|
|
|
for (let index = 0; index < count; index += 1) {
|
|
|
|
|
const t = index / count; // 0 = 성토끝(맨 아래 단), 1에 가까울수록 노견 쪽
|
|
|
|
|
const anchors: RevetPoint[] = [anchor];
|
|
|
|
|
const drop = anchor.elevation - toe.elevation;
|
|
|
|
|
const run = toe.offset - anchor.offset;
|
|
|
|
|
if (!(drop > 1e-6) || !(height > 0)) return anchors;
|
|
|
|
|
for (let index = 1; index < count; index += 1) {
|
|
|
|
|
const fall = height * index;
|
|
|
|
|
if (fall > drop + 1e-6) break; // 성토면 끝을 지난다 — 자리 없음
|
|
|
|
|
anchors.push({
|
|
|
|
|
offset: toe.offset + (edge.offset_m - toe.offset) * t,
|
|
|
|
|
elevation: toe.elevation + (edge.elevation_m - toe.elevation) * t,
|
|
|
|
|
offset: anchor.offset + (run * fall) / drop,
|
|
|
|
|
elevation: anchor.elevation - fall,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return anchors;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 독립 기슭막이 단면. 제원·높이가 없거나 성토면 끝을 못 찾으면 null. */
|
|
|
|
|
function anchorAt(
|
|
|
|
|
section: CrossSection,
|
|
|
|
|
side: "left" | "right",
|
|
|
|
|
toe: RevetPoint,
|
|
|
|
|
liftM: number,
|
|
|
|
|
shiftM: number,
|
|
|
|
|
): RevetPoint {
|
|
|
|
|
let anchor = toe;
|
|
|
|
|
const edge = section.design?.road_edges?.[side];
|
|
|
|
|
if (edge && liftM > 0) {
|
|
|
|
|
const run = edge.offset_m - toe.offset;
|
|
|
|
|
const rise = edge.elevation_m - toe.elevation;
|
|
|
|
|
const length = Math.hypot(run, rise);
|
|
|
|
|
if (length > 1e-6) {
|
|
|
|
|
const t = Math.min(liftM / length, 1);
|
|
|
|
|
anchor = { offset: toe.offset + run * t, elevation: toe.elevation + rise * t };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!shiftM) return anchor;
|
|
|
|
|
// 좌우는 수평 이동이라 표고는 그 자리 원지반을 따른다(벽 밑이 뜨지 않게).
|
|
|
|
|
const outward = side === "left" ? 1 : -1;
|
|
|
|
|
const offset = anchor.offset + outward * shiftM;
|
|
|
|
|
const ground = groundElevationAt(section, offset);
|
|
|
|
|
return { offset, elevation: ground ?? anchor.elevation };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 독립 기슭막이 단면. 제원·높이가 없거나 성토면 끝을 못 찾으면 null.
|
|
|
|
|
*
|
|
|
|
|
* 다단 전개는 배관 세트 기슭막이와 **같은 규칙**이다(2026-08-22 확정, 2026-08-28 승계):
|
|
|
|
|
* · 기준(1단)이 맨 위, 추가 단은 그 **아래**로 붙는다.
|
|
|
|
|
* · 다음 단 성토선 시작점 = 윗단 벽 하단 수평선 +근입(0.5m)과 전면 경사선(1:0.3)의 교차점.
|
|
|
|
|
* · 그 시작점이 이미 원지반 아래면(벽이 묻힘) 더 세우지 않는다 — 화면이 "자리가 없다"고
|
|
|
|
|
* 알리고, 사용자가 기준을 위로 올린 뒤(`lift_m`) 다시 늘린다.
|
|
|
|
|
* · 단 사이 성토사면은 정확히 1:1.2.
|
|
|
|
|
*/
|
|
|
|
|
export function computeRevetmentLayout(section: CrossSection): RevetmentLayout | null {
|
|
|
|
|
const spec = section.revetment;
|
|
|
|
|
const height = Number(spec?.height_m);
|
|
|
|
@@ -160,27 +202,29 @@ export function computeRevetmentLayout(section: CrossSection): RevetmentLayout |
|
|
|
|
|
if (!toe) return null;
|
|
|
|
|
|
|
|
|
|
const outward = side === "left" ? 1 : -1;
|
|
|
|
|
const count = Math.max(1, Math.round(Number(spec.tiers) || 1));
|
|
|
|
|
const tiers: RevetmentTier[] = tierAnchors(section, side, toe, count).map((anchor) => {
|
|
|
|
|
const bottomElevation = anchor.elevation - height - REVET_EMBED_DEPTH_M;
|
|
|
|
|
const frontTop = anchor.offset + outward * REVET_THICKNESS_M;
|
|
|
|
|
const frontBottom =
|
|
|
|
|
frontTop + outward * REVET_LEAN_RATIO * (anchor.elevation - bottomElevation);
|
|
|
|
|
const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1));
|
|
|
|
|
const anchor = anchorAt(section, side, toe, Number(spec.lift_m) || 0, Number(spec.shift_m) || 0);
|
|
|
|
|
const tiers: RevetmentTier[] = tierAnchors(anchor, toe, height, requestedTiers).map((top) => {
|
|
|
|
|
const bottomElevation = top.elevation - height - REVET_EMBED_DEPTH_M;
|
|
|
|
|
const frontTop = top.offset + outward * REVET_THICKNESS_M;
|
|
|
|
|
const frontBottom = frontTop + outward * REVET_LEAN_RATIO * (top.elevation - bottomElevation);
|
|
|
|
|
return {
|
|
|
|
|
top: anchor,
|
|
|
|
|
top,
|
|
|
|
|
bottomElevation,
|
|
|
|
|
polygon: [
|
|
|
|
|
{ offset: anchor.offset, elevation: anchor.elevation },
|
|
|
|
|
{ offset: frontTop, elevation: anchor.elevation },
|
|
|
|
|
{ offset: top.offset, elevation: top.elevation },
|
|
|
|
|
{ offset: frontTop, elevation: top.elevation },
|
|
|
|
|
{ offset: frontBottom, elevation: bottomElevation },
|
|
|
|
|
{ offset: anchor.offset, elevation: bottomElevation },
|
|
|
|
|
{ offset: top.offset, elevation: bottomElevation },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
side,
|
|
|
|
|
top: toe,
|
|
|
|
|
chainageM: Number(section.chainage_m) || 0,
|
|
|
|
|
requestedTiers,
|
|
|
|
|
top: tiers[0]?.top ?? anchor,
|
|
|
|
|
tiers,
|
|
|
|
|
span: {
|
|
|
|
|
beforeM: Math.max(spec.anchor_m - spec.start_m, 0),
|
|
|
|
@@ -189,6 +233,28 @@ export function computeRevetmentLayout(section: CrossSection): RevetmentLayout |
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 자리가 없어 못 세운 단이 있으면 **한 번만** 알린다 — 카드마다 띄우면 화면이 덮인다. */
|
|
|
|
|
const shortfallStations = new Set<number>();
|
|
|
|
|
let shortfallTimer = 0;
|
|
|
|
|
let shortfallLast = "";
|
|
|
|
|
|
|
|
|
|
function noticeShortfall(layout: RevetmentLayout): void {
|
|
|
|
|
if (layout.tiers.length >= layout.requestedTiers) return;
|
|
|
|
|
// 같은 측점이 다시 그려져도 한 번만 센다 — 카드는 조작할 때마다 다시 그린다.
|
|
|
|
|
shortfallStations.add(Math.round(layout.chainageM * 100));
|
|
|
|
|
window.clearTimeout(shortfallTimer);
|
|
|
|
|
shortfallTimer = window.setTimeout(() => {
|
|
|
|
|
const message =
|
|
|
|
|
`기슭막이 ${layout.requestedTiers}단 중 ${layout.tiers.length}단만 세울 수 있습니다` +
|
|
|
|
|
`(측점 ${shortfallStations.size}곳) — 아래 자리가 원지반에 묻힙니다. ` +
|
|
|
|
|
"기준 올림(사면 위로)으로 1단을 올린 뒤 단을 늘리세요.";
|
|
|
|
|
shortfallStations.clear();
|
|
|
|
|
if (message === shortfallLast) return;
|
|
|
|
|
shortfallLast = message;
|
|
|
|
|
showToast(message, "warning");
|
|
|
|
|
}, 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 횡단 카드에 벽 단면을 그린다(다단이면 단마다 하나). 그렸으면 true. */
|
|
|
|
|
export function appendRevetmentOverlay(
|
|
|
|
|
svg: SVGElement,
|
|
|
|
@@ -197,6 +263,7 @@ export function appendRevetmentOverlay(
|
|
|
|
|
y: (elevation: number) => number,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (!layout || !layout.tiers.length) return false;
|
|
|
|
|
noticeShortfall(layout);
|
|
|
|
|
for (const tier of layout.tiers) {
|
|
|
|
|
const polygon = document.createElementNS(SVG_NS, "polygon");
|
|
|
|
|
polygon.setAttribute(
|
|
|
|
|