- 좌우 ◀▶ = 노견(추가 벽은 윗단 0.5m 기준선) 수평 연장 평행이동 —
성토선 물매 1:1.2·상단 표고 불변, 지반에 못 닿으면 바닥 뜸 허용
- 상하 ▲▼ = 성토선을 타는 대각 이동(수평 성분 1m), 위 한계 =
최소 성토고·토피(역경사), 추가 벽은 윗단 하단 관통 금지
- 높이 ±0.1m: 배관 벽 2.0~재질한계(기본 2.5, 메쌓기면 2.0로 절삭),
일반 벽 0.5~재질한계(기본 1.5). 높이 기준 = 근입 0.5 위 기준선~상단
- 재질 선택(메쌓기 2.0/찰쌓기 3.0/콘크리트 5.0m) — 한계 초과 시
토스트로 재질 변경 안내, 자동 높이는 기본값 고정(사용자 확정)
- 다단 단 수: 숫자 입력 + +/- 버튼 병행, 입력기 스피너 제거
- 저장 스키마: revetx 값이 {x,d,h,m} 객체로 확장(구 숫자 형식 호환)
- 인터페이스 3종을 Wire로 이전(Cross_View 700줄 제한)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
264 lines
11 KiB
TypeScript
264 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 {
|
||
EXTRA_WALL_DEFAULT_HEIGHT_M,
|
||
EXTRA_WALL_MIN_HEIGHT_M,
|
||
FILL_SLOPE_MAX_LENGTH_M,
|
||
FILL_SLOPE_RATIO_MIN,
|
||
materialLabel,
|
||
materialLimit,
|
||
REVET_EMBED_DEPTH_M,
|
||
REVET_LEAN_RATIO,
|
||
REVET_THICKNESS_M,
|
||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||
import type {
|
||
OffsetPoint,
|
||
OutletFillSegment,
|
||
WallAdjust,
|
||
WallLayout,
|
||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||
|
||
export interface OutletExtrasInput {
|
||
/** 성토부선 시작점 = 관 유출 하단 꼭짓점(배관 벽의 성토선 시작 자리). */
|
||
start: OffsetPoint;
|
||
/** 배관 벽 하단(수평 기초) 표고 — 1단 벽 상단이 이 아래에 있어야 한다. */
|
||
startBottomElevation: number;
|
||
outward: number;
|
||
groundAt: (offset: number) => number;
|
||
/** 계류측 샘플 한계 offset. */
|
||
limitOffset: number;
|
||
/** 단별 사용자 조작값(좌우 x·상하 d·높이 h·재질 m) — 요청한 단 수만큼. */
|
||
adjusts: WallAdjust[];
|
||
}
|
||
|
||
export interface OutletExtrasResult {
|
||
walls: WallLayout[];
|
||
segments: OutletFillSegment[];
|
||
/** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다. */
|
||
appliedAdjusts: WallAdjust[];
|
||
/** 끝 성토부가 아직 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,
|
||
material: RevetMaterial,
|
||
floatGapM: number,
|
||
): WallLayout {
|
||
const thickness = REVET_THICKNESS_M;
|
||
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
|
||
const backOffset = 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);
|
||
// 하단 = 기준선(anchor) 아래 근입 0.5m 고정 — 높이 기준 "근입 0.5 위~상단"과 일치.
|
||
const bottomElevation = anchor.elevation - 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: materialLabel(material),
|
||
lengthM: null,
|
||
backOffset,
|
||
outerOffset: bottomFront.offset,
|
||
base: anchor.elevation,
|
||
height,
|
||
floatGapM,
|
||
material,
|
||
outward,
|
||
topBack,
|
||
topJoint: { offset: topJoint, elevation: topElevation },
|
||
bottomBack,
|
||
bottomFront,
|
||
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 유출측 성토부선·다단 기슭막이 일괄 계산 (2026-08-22 4축 조작 체계).
|
||
*
|
||
* 단별 벽 높이 h = 사용자 설정 ?? 기본 1.5m (0.5~재질 한계로 절삭). 벽 상단은
|
||
* 항상 src에서 내려오는 **1:1.2 성토선 위** — 자동 자리는 높이 h 벽이 지반에
|
||
* 앉으며 상단이 선에 닿는 지점(heightAt(x)=h 교차점, 닫힌식):
|
||
* heightAt(x) = (src.elev − 지반(x) − (D − 0.25t)/1.2) / (1 − 0.15/1.2)
|
||
* 조작: 좌우 x = src 높이의 **수평 선반**을 끼워 평행이동(물매 불변, 상단 표고
|
||
* 불변 — 윗단 기준 0.5m 수평 구현), 상하 d = 성토선을 타는 대각(수평 성분).
|
||
* 위(−d) 한계 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단). 바닥은 상단−h−0.5로
|
||
* 지반과 무관 — 못 닿으면 뜨고(floatGap 경고), 지나면 묻힌다.
|
||
* 요청 단 수보다 지형이 허락하는 단이 적으면 되는 만큼만 세운다.
|
||
*/
|
||
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
|
||
const { outward, groundAt, 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 appliedAdjusts: WallAdjust[] = [];
|
||
let src = input.start;
|
||
let prevBottom = input.startBottomElevation;
|
||
|
||
for (let i = 0; i < input.adjusts.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;
|
||
|
||
const adjust = input.adjusts[i];
|
||
const material = adjust.m ?? "dry";
|
||
const limit = materialLimit(material);
|
||
const height = Math.min(
|
||
Math.max(adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, EXTRA_WALL_MIN_HEIGHT_M),
|
||
limit,
|
||
);
|
||
|
||
/** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */
|
||
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
|
||
);
|
||
};
|
||
// 자동 자리 = heightAt이 사용자 높이 h에 처음 닿는 지점(0.05m 스캔 후 선형 보간).
|
||
// 지형이 완만해 h까지 못 크면 **가장 깊이 앉는 지점**(heightAt 최대)으로 폴백 —
|
||
// 바닥은 상단−h−0.5라 지반과 어긋나도 된다(뜨면 경고, 묻히면 그대로).
|
||
const step = 0.05;
|
||
const span = (limitOffset - src.offset) * outward;
|
||
let autoOffset: number | null = null;
|
||
let bestOffset: number | null = null;
|
||
let bestHeight = 0.05; // 이 이하로만 앉는 지형이면 단을 세우지 않는다
|
||
let previousShort = 0; // 직전 스캔점의 h 부족량(heightAt < h) — 보간용
|
||
for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) {
|
||
const x = src.offset + outward * t;
|
||
const gap = heightAt(x) - height;
|
||
if (gap >= 0) {
|
||
// 교차점 선형 보간 — 격자 대신 정확 자리(사면선·지반 동시 접점).
|
||
const back = previousShort + gap > 1e-9 ? (gap / (previousShort + gap)) * step : 0;
|
||
autoOffset = x - outward * back;
|
||
break;
|
||
}
|
||
previousShort = -gap;
|
||
if (heightAt(x) > bestHeight) {
|
||
bestHeight = heightAt(x);
|
||
bestOffset = x;
|
||
}
|
||
}
|
||
if (autoOffset === null) autoOffset = bestOffset;
|
||
if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만.
|
||
// 관통 금지: 상단 ≤ 윗단 하단 → 성토선 수평거리(경사부) ≥ 1.2×(시작−윗단하단).
|
||
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(height);
|
||
const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0);
|
||
const appliedD = Math.max(adjust.d, Math.ceil((minJointRun - autoJointRun) * 10) / 10);
|
||
const appliedX = Math.max(adjust.x, 0);
|
||
appliedAdjusts.push({
|
||
x: appliedX,
|
||
d: appliedD,
|
||
h: adjust.h != null ? height : null,
|
||
m: adjust.m,
|
||
});
|
||
|
||
const slopeRun = autoJointRun + appliedD;
|
||
const topElevation = src.elevation - slopeRun / FILL_SLOPE_RATIO_MIN;
|
||
const anchorX = autoOffset + outward * (appliedX + appliedD);
|
||
const base = topElevation - height; // 근입 0.5 위 기준선
|
||
const wall = buildExtraWall(
|
||
i,
|
||
{ offset: anchorX, elevation: base },
|
||
outward,
|
||
height,
|
||
material,
|
||
Math.max(0, base - groundAt(anchorX)),
|
||
);
|
||
walls.push(wall);
|
||
// 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부.
|
||
const shelf: OffsetPoint | null =
|
||
appliedX > 1e-9
|
||
? { offset: src.offset + outward * appliedX, elevation: src.elevation }
|
||
: null;
|
||
const slopeFrom = shelf ?? src;
|
||
const run = Math.abs(wall.topJoint.offset - slopeFrom.offset);
|
||
const rise = slopeFrom.elevation - wall.topJoint.elevation;
|
||
segments.push({
|
||
points: shelf ? [src, shelf, wall.topJoint] : [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)의 교차점.
|
||
src = {
|
||
offset: wall.points[2].offset + outward * REVET_LEAN_RATIO * height,
|
||
elevation: base,
|
||
};
|
||
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, appliedAdjusts, addable };
|
||
}
|