Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts
T
eomsangdonandClaude Opus 5 8167dd23c3 feat(B06): 다단 기슭막이 규칙 재정립 — 정확한 1:1.2·매몰 종단·단 수 입력
- 벽 사이 성토사면 = 정확히 1:1.2 — 벽 높이를 자리에서 역산(닫힌식),
  0.1m 눈금 올림 제거(물매 정확 유지 우선)
- 아랫단 상단은 윗단 하단(수평 기초) 관통 금지 — 안쪽 이동 한계
- 다음 단 성토선 시작 = 윗단 하단 +0.5m 수평선 × 전면 경사선(1:0.3) 교차점
  (배관 벽의 관 하단 꼭짓점과 같은 자리)
- 시작점이 원지반 아래(벽 0.5m 이상 매몰)면 성토 불필요 — 다단 종료
- 추가 방식 = 유출 벽 조정창의 단 수 숫자 입력(+/- 버튼 대체),
  지형 허용 단 수 초과 시 토스트로 가능한 단 수 안내 후 되는 만큼만 반영

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

250 lines
11 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_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 };
}