- 유입구·유출구 이름표에 지금 만지는 구조물을 괄호로 단다 — "유입구 (기슭막이)".
추가 기슭막이 칸은 "추가 기슭막이 (2단)". 선택이 풀리면 원래 문구로 돌아간다.
- 추가 기슭막이 칸이 설 때 그 자리로 스크롤한다(칸이 화면 밖에서 생겼다).
- 독립 기슭막이 형태·높이가 그림에 안 실리던 두 자리를 고친다:
① 정본 읽기가 옛 전용 키(form·height_m)만 봐서 배관 키 한 벌로 저장된
값을 놓쳤다 → 배관 키 우선, 옛 키는 폴백.
② 다단 전개에 소유 벽 형태를 안 넘겨 단들이 늘 메쌓기로 그려졌다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
382 lines
16 KiB
TypeScript
382 lines
16 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
|
||
const side: "left" | "right" = spec.side === "우" ? "right" : "left";
|
||
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,
|
||
});
|
||
|
||
// 다단 — 배관과 같은 함수. 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` : "");
|
||
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);
|
||
};
|
||
}
|