- 벽 사이 성토사면 = 정확히 1:1.2 — 벽 높이를 자리에서 역산(닫힌식), 0.1m 눈금 올림 제거(물매 정확 유지 우선) - 아랫단 상단은 윗단 하단(수평 기초) 관통 금지 — 안쪽 이동 한계 - 다음 단 성토선 시작 = 윗단 하단 +0.5m 수평선 × 전면 경사선(1:0.3) 교차점 (배관 벽의 관 하단 꼭짓점과 같은 자리) - 시작점이 원지반 아래(벽 0.5m 이상 매몰)면 성토 불필요 — 다단 종료 - 추가 방식 = 유출 벽 조정창의 단 수 숫자 입력(+/- 버튼 대체), 지형 허용 단 수 초과 시 토스트로 가능한 단 수 안내 후 되는 만큼만 반영 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* B06_Section_UI_Cross_Culvert_Extra.ts
|
||
* 유출측 **성토부선 + 추가 기슭막이(다단)** — Geom에서 분리(700줄 제한).
|
||
*
|
||
* 다단 기슭막이는 **1:1.2 고정 물매에서 안정적으로 성토고를 올리는 수단**이다
|
||
* (2026-08-22 사용자 확정 규칙):
|
||
* · 기슭막이 사이 성토사면은 **정확히 1:1.2** — 벽 높이를 자리에 맞춰 역산한다.
|
||
* · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**.
|
||
* · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:0.3)의
|
||
* 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다).
|
||
* · 그 시작점이 원지반 아래면(벽이 0.5m 이상 묻힘) **성토 불필요** — 다단 종료.
|
||
* ========================================================================== */
|
||
|
||
import {
|
||
FILL_SLOPE_MAX_LENGTH_M,
|
||
FILL_SLOPE_RATIO_MIN,
|
||
REVET_EMBED_DEPTH_M,
|
||
REVET_LEAN_RATIO,
|
||
REVET_THICKNESS_M,
|
||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||
import type {
|
||
OffsetPoint,
|
||
OutletFillSegment,
|
||
WallLayout,
|
||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||
|
||
/** 추가 벽 최소 높이(m, 임시) — 이보다 낮아지는 자리(바깥)로는 밀 수 없다. */
|
||
export const EXTRA_MIN_HEIGHT_M = 0.5;
|
||
|
||
export interface OutletExtrasInput {
|
||
/** 성토부선 시작점 = 관 유출 하단 꼭짓점(배관 벽의 성토선 시작 자리). */
|
||
start: OffsetPoint;
|
||
/** 배관 벽 하단(수평 기초) 표고 — 1단 벽 상단이 이 아래에 있어야 한다. */
|
||
startBottomElevation: number;
|
||
outward: number;
|
||
groundAt: (offset: number) => number;
|
||
/** 표준 높이(관경+여유고) — 자동 자리는 이 높이가 딱 맞는 지점이다. */
|
||
wallHeight: number;
|
||
/** 형태별 높이 한계(찰 3.0/메 2.0) — 안쪽 당김 한계. */
|
||
heightLimit: number;
|
||
form: string | null;
|
||
/** 계류측 샘플 한계 offset. */
|
||
limitOffset: number;
|
||
/** 사용자가 민 이동량(m, + = 계류측 바깥) — 요청한 단 수만큼. */
|
||
shifts: number[];
|
||
}
|
||
|
||
export interface OutletExtrasResult {
|
||
walls: WallLayout[];
|
||
segments: OutletFillSegment[];
|
||
/** 한계에 잘린 뒤의 실제 이동량 — 조정창이 되받는다. */
|
||
appliedShifts: number[];
|
||
/** 끝 성토부가 아직 5m 이상 — 단을 더 둘 수 있다(의무 구간). */
|
||
addable: boolean;
|
||
}
|
||
|
||
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */
|
||
function jointRunOf(height: number): number {
|
||
return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height;
|
||
}
|
||
|
||
/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */
|
||
function trailingFill(
|
||
src: OffsetPoint,
|
||
outward: number,
|
||
groundAt: (offset: number) => number,
|
||
): OutletFillSegment {
|
||
const points: OffsetPoint[] = [src];
|
||
let length = 0;
|
||
let previous = src;
|
||
const step = 0.25;
|
||
for (let t = step; t <= 30 + 1e-9; t += step) {
|
||
const offset = src.offset + outward * t;
|
||
const slopeElevation = src.elevation - t / FILL_SLOPE_RATIO_MIN;
|
||
const groundElevation = groundAt(offset);
|
||
const point: OffsetPoint = { offset, elevation: Math.max(slopeElevation, groundElevation) };
|
||
length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation);
|
||
points.push(point);
|
||
previous = point;
|
||
if (slopeElevation <= groundElevation) break;
|
||
}
|
||
return {
|
||
points,
|
||
lengthM: length,
|
||
ratio: FILL_SLOPE_RATIO_MIN,
|
||
overLimit: length >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 추가 기슭막이 벽 1매 — 배관용 기슭막이와 같은 형상(배면 수직·전면 1:0.3,
|
||
* 수평 기초 근입 0.5m), 배관·마감면만 없다. 높이는 눈금 없이 **정확값**을 쓴다 —
|
||
* 0.1m 올림을 하면 상단이 1:1.2 사면선을 벗어난다(사이 물매 정확 유지가 우선).
|
||
*/
|
||
function buildExtraWall(
|
||
index: number,
|
||
anchor: OffsetPoint,
|
||
outward: number,
|
||
height: number,
|
||
form: string | null,
|
||
groundAt: (offset: number) => number,
|
||
): WallLayout {
|
||
const thickness = REVET_THICKNESS_M;
|
||
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
|
||
const backOffset = anchor.offset - outward * (baseWidth / 2);
|
||
const frontBase = anchor.offset + outward * (baseWidth / 2);
|
||
const topJoint = backOffset + outward * (thickness / 2);
|
||
const topElevation = anchor.elevation + height;
|
||
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
|
||
const topFront = topJoint + outward * thickness;
|
||
const frontXAt = (elevation: number): number =>
|
||
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
|
||
let bottomElevation =
|
||
Math.min(groundAt(backOffset), groundAt(frontBase), anchor.elevation) - REVET_EMBED_DEPTH_M;
|
||
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
|
||
bottomElevation = Math.min(bottomElevation, toeGround - REVET_EMBED_DEPTH_M);
|
||
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
|
||
const bottomFront: OffsetPoint = {
|
||
offset: frontXAt(bottomElevation),
|
||
elevation: bottomElevation,
|
||
};
|
||
return {
|
||
role: "extra",
|
||
extraIndex: index,
|
||
form,
|
||
lengthM: null,
|
||
backOffset,
|
||
outerOffset: bottomFront.offset,
|
||
base: anchor.elevation,
|
||
height,
|
||
floatGapM: 0,
|
||
outward,
|
||
topBack,
|
||
topJoint: { offset: topJoint, elevation: topElevation },
|
||
bottomBack,
|
||
bottomFront,
|
||
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 유출측 성토부선·다단 기슭막이 일괄 계산.
|
||
*
|
||
* 각 단의 벽 높이는 자리에서 역산한다 — 이음선 상단점이 src에서 내려오는
|
||
* **1:1.2 사면선 위에 정확히** 놓이는 높이. 닫힌식(D = 벽 중심~src 수평거리):
|
||
* 이음선 자리 R = D − jointRun(h), 상단 = src.elev − R/1.2 = 지반(x) + h
|
||
* → h = (src.elev − 지반(x) − (D − 0.25t)/1.2) / (1 − 0.15/1.2)
|
||
* 자동 자리 = h가 표준 높이(관경+여유고)에 처음 닿는 **가장 안쪽 지점**.
|
||
* 이동 한계: 안쪽 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단)·높이 한계(찰3/메2),
|
||
* 바깥쪽 = 최소 높이 0.5m. 요청 단 수보다 지형이 허락하는 단이 적으면 되는
|
||
* 만큼만 세운다 — 조정창이 개수 차이를 보고 토스트로 가능한 단 수를 알린다.
|
||
*/
|
||
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
|
||
const { outward, groundAt, wallHeight, heightLimit, form, limitOffset } = input;
|
||
const thickness = REVET_THICKNESS_M;
|
||
const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN;
|
||
const walls: WallLayout[] = [];
|
||
const segments: OutletFillSegment[] = [];
|
||
const appliedShifts: number[] = [];
|
||
let src = input.start;
|
||
let prevBottom = input.startBottomElevation;
|
||
|
||
for (let i = 0; i < input.shifts.length; i += 1) {
|
||
// 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료.
|
||
if (groundAt(src.offset) >= src.elevation - 0.01) break;
|
||
const trailing = trailingFill(src, outward, groundAt);
|
||
if (trailing.lengthM < 0.7) break;
|
||
|
||
/** 자리 x(벽 하단 중점)에서 1:1.2를 정확히 지키는 벽 높이(닫힌식). */
|
||
const heightAt = (x: number): number => {
|
||
const run = (x - src.offset) * outward;
|
||
return (
|
||
(src.elevation - groundAt(x) - (run - 0.25 * thickness) / FILL_SLOPE_RATIO_MIN) /
|
||
heightDenominator
|
||
);
|
||
};
|
||
// 아랫단 상단이 윗단 하단을 넘지 않을 이음선 최소 수평거리(상단 ≤ 윗단 하단).
|
||
const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0);
|
||
const feasible = (x: number): boolean => {
|
||
const h = heightAt(x);
|
||
return (
|
||
h >= EXTRA_MIN_HEIGHT_M - 1e-9 &&
|
||
h <= heightLimit + 1e-9 &&
|
||
(x - src.offset) * outward - jointRunOf(h) >= minJointRun - 1e-6
|
||
);
|
||
};
|
||
// 자동 자리 = 표준 높이가 처음 성립하는 가장 안쪽 지점(0.05m 스캔). 표준까지
|
||
// 못 크는 지형이면 조건을 만족하는 첫 자리로 폴백.
|
||
const step = 0.05;
|
||
const span = (limitOffset - src.offset) * outward;
|
||
let auto: number | null = null;
|
||
let fallback: number | null = null;
|
||
for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) {
|
||
const x = src.offset + outward * t;
|
||
if (!feasible(x)) continue;
|
||
if (fallback === null) fallback = x;
|
||
if (heightAt(x) >= wallHeight - 1e-9) {
|
||
auto = x;
|
||
break;
|
||
}
|
||
}
|
||
const autoOffset = auto ?? fallback;
|
||
if (autoOffset === null) break; // 이 단은 세울 자리가 없다 — 되는 만큼만.
|
||
// 사용자 이동 반영 후, 안 되는 자리면 되는 쪽으로 0.05m씩 되돌린다
|
||
// (안쪽 위반 → 바깥으로, 바깥 위반(최소 높이 미달) → 안쪽으로).
|
||
let shifted = autoOffset + outward * input.shifts[i];
|
||
for (let pass = 0; pass < 400 && !feasible(shifted); pass += 1) {
|
||
const h = heightAt(shifted);
|
||
const tooInner =
|
||
h > heightLimit ||
|
||
(shifted - src.offset) * outward - jointRunOf(Math.min(h, heightLimit)) < minJointRun;
|
||
shifted += outward * (tooInner ? step : -step);
|
||
}
|
||
if (!feasible(shifted)) break;
|
||
appliedShifts.push(Math.round((shifted - autoOffset) * outward * 10) / 10);
|
||
|
||
const height = heightAt(shifted);
|
||
const anchor: OffsetPoint = { offset: shifted, elevation: groundAt(shifted) };
|
||
const wall = buildExtraWall(i, anchor, outward, height, form, groundAt);
|
||
walls.push(wall);
|
||
// src → 이음선 상단점 — 정확히 1:1.2(높이 역산으로 보장).
|
||
const run = Math.abs(wall.topJoint.offset - src.offset);
|
||
const rise = src.elevation - wall.topJoint.elevation;
|
||
segments.push({
|
||
points: [src, wall.topJoint],
|
||
lengthM: Math.hypot(run, rise),
|
||
ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN,
|
||
overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
|
||
});
|
||
// 다음 단 시작점 = 이 벽 하단 +0.5m 수평선과 전면 경사선(1:0.3)의 교차점.
|
||
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
|
||
src = {
|
||
offset:
|
||
wall.points[2].offset +
|
||
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
|
||
elevation: startElevation,
|
||
};
|
||
prevBottom = wall.bottomBack.elevation;
|
||
}
|
||
|
||
// 끝 성토부 — 마지막 구조물의 시작점이 지반 위일 때만 그린다(묻히면 성토 불필요).
|
||
let addable = false;
|
||
if (groundAt(src.offset) < src.elevation - 0.01) {
|
||
const tail = trailingFill(src, outward, groundAt);
|
||
segments.push(tail);
|
||
addable = tail.overLimit;
|
||
}
|
||
return { walls, segments, appliedShifts, addable };
|
||
}
|