Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts
T
eomsangdonandClaude Opus 5 de78feabde feat(B06): 다단 등간격 배치 버튼 + 유입 0도 접속선 + 유출 매몰 절토선·이동 금지
① 십자 우측 하단 ≡ 버튼 — 벽 사이 사면 구간을 같게 재배치(끝 성토부는 지형
   결정값이라 제외). 다단 연쇄(아랫단 바닥 하강) 때문에 1회 분할이 아니라
   구간 오차를 d로 되먹임하는 수렴 반복(≤8회, 허용 0.05m)으로 푼다.
② 유입 기슭막이 관 시작 접속선: 관 하단점이 원지반보다 위면 0도 성토선
   (지반 교차에서 정지), 아래면 0도 1m 후 표준 절토 경사(1:n)로 지반까지.
③ 유출 마지막 구조물 시작점이 원지반에 묻히면 0도 절토선을 지반 교차까지
   연장, 교차가 없는 자리는 이동 금지(배관 벽·추가 벽 공통 클램프).
- 배관 벽 4축 배치를 placePipeWall(Solve)로 추출 — Geom 700줄 유지

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

448 lines
20 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 {
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[];
/** 1회성 등간격 배치(2026-08-22 사용자 ①) — 단별 d를 사면 구간이 같아지게 재계산. */
equalize?: boolean;
}
export interface OutletExtrasResult {
walls: WallLayout[];
segments: OutletFillSegment[];
/** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다. */
appliedAdjusts: WallAdjust[];
/** 끝 성토부가 아직 5m 이상 — 단을 더 둘 수 있다(의무 구간). */
addable: boolean;
}
/** 수평선(0도)이 계류측으로 나가며 원지반 아래로 다시 떨어지는 첫 지점(절토 교차).
* 없으면 null — 그 자리는 매몰 상태로 둘 수 없다(2026-08-22 사용자 ③ 이동 금지). */
export function horizontalCrossing(
from: OffsetPoint,
outward: number,
groundAt: (offset: number) => number,
limitOffset: number,
): number | null {
const span = (limitOffset - from.offset) * outward;
for (let t = 0.25; t <= span + 1e-9; t += 0.25) {
const x = from.offset + outward * t;
if (groundAt(x) <= from.elevation) return x;
}
return null;
}
/**
* 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②).
* · 관 하단점이 원지반보다 **위**: 0도 성토선 — 지반과 만나는 곳에서 정지.
* · **아래**: 0도 직선 1m 후 표준 절토 경사(1:n)로 지반까지 올라가는 절토선.
*/
export function inletGroundConnector(
corner: OffsetPoint,
outward: number,
groundAt: (offset: number) => number,
cutSlopeRatio: number,
limitOffset: number,
): OutletFillSegment | null {
const ground = groundAt(corner.offset);
const span = (limitOffset - corner.offset) * outward;
if (corner.elevation > ground + 0.02) {
let cross = corner.offset + outward * Math.max(span, 0);
for (let t = 0.25; t <= span + 1e-9; t += 0.25) {
const x = corner.offset + outward * t;
if (groundAt(x) >= corner.elevation) {
cross = x;
break;
}
}
return {
points: [corner, { offset: cross, elevation: corner.elevation }],
lengthM: Math.abs(cross - corner.offset),
overLimit: false,
kind: "fill",
};
}
if (corner.elevation < ground - 0.02) {
const bend: OffsetPoint = {
offset: corner.offset + outward * 1.0,
elevation: corner.elevation,
};
let last = bend;
const cutSpan = (limitOffset - bend.offset) * outward;
for (let t = 0.25; t <= Math.max(cutSpan, 0) + 1e-9; t += 0.25) {
const x = bend.offset + outward * t;
const elevation = bend.elevation + t / Math.max(cutSlopeRatio, 0.1);
last = { offset: x, elevation };
if (elevation >= groundAt(x)) break;
}
return {
points: [corner, bend, last],
lengthM: 1 + Math.hypot(last.offset - bend.offset, last.elevation - bend.elevation),
overLimit: false,
kind: "cut",
};
}
return null;
}
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */
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,
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 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까지 더 내린다(2026-08-22 사용자 ②).
let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
if (floatGapM <= 1e-6) {
for (let pass = 0; pass < 6; pass += 1) {
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break;
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: 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;
/** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */
const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => {
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);
// 등간격 배치: 1차는 남은 구간(끝 포함) 균등 분할 근사, 이후 라운드는 보정 d.
const requestedD =
plan === "user"
? adjust.d
: plan === "greedy"
? (trailing.lengthM / (input.adjusts.length - i + 1)) *
(FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN)) -
autoJointRun
: (plan[i] ?? 0);
const requestedX = plan === "user" ? adjust.x : 0;
const minD = Math.ceil((minJointRun - autoJointRun) * 10) / 10;
/** 후보 자리의 바닥(전면 근입 심화 반영) — buildExtraWall과 같은 계산. */
const bottomAt = (anchorX: number, baseElevation: number): number => {
const baseWidth = REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
const jointX = anchorX - outward * (baseWidth / 2 - REVET_THICKNESS_M / 2);
const topFrontX = jointX + outward * REVET_THICKNESS_M;
const topElevation2 = baseElevation + height;
const frontXAt = (e: number): number =>
topFrontX + outward * REVET_LEAN_RATIO * (topElevation2 - e);
let bottom = baseElevation - REVET_EMBED_DEPTH_M;
if (baseElevation - groundAt(anchorX) <= 1e-6) {
for (let pass = 0; pass < 6; pass += 1) {
const toe = Math.min(groundAt(frontXAt(bottom)), baseElevation);
if (toe - REVET_EMBED_DEPTH_M >= bottom - 1e-6) break;
bottom = toe - REVET_EMBED_DEPTH_M;
}
}
return bottom;
};
// 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이
// 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이
// 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리).
const placeable = (xShift: number, dShift: number): boolean => {
const topE = src.elevation - (autoJointRun + dShift) / FILL_SLOPE_RATIO_MIN;
const aX = autoOffset + outward * (xShift + dShift);
const jointX = aX - outward * jointRunOf(height);
if (
Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >=
topE - 0.01
)
return false;
const baseE = topE - height;
const bottom = bottomAt(aX, baseE);
const startE = bottom + REVET_EMBED_DEPTH_M;
const topFrontX = jointX + outward * REVET_THICKNESS_M;
const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE);
if (groundAt(startX) < startE + 0.05) return true;
// 매몰 — 0도 절토선이 원지반과 다시 만나면 허용(2026-08-22 ③), 아니면 이동 금지.
return (
horizontalCrossing(
{ offset: startX, elevation: startE },
outward,
groundAt,
limitOffset,
) !== null
);
};
let appliedD = Math.max(requestedD, minD);
let appliedX = Math.max(requestedX, 0);
for (let pass = 0; pass < 400 && !placeable(appliedX, appliedD); pass += 1) {
// 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
if (appliedD > minD + 1e-9) appliedD = Math.max(minD, appliedD - 0.05);
else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05);
else break;
}
if (!placeable(appliedX, appliedD)) break; // 자동 자리조차 매몰 — 이 단은 불가.
appliedD = Math.round(appliedD * 10) / 10;
appliedX = Math.round(appliedX * 10) / 10;
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)),
groundAt,
);
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)의 교차점(2026-08-22 사용자 — 관 끝점 규칙과 동일 기준).
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;
}
// 끝 구간(2026-08-22 사용자 ③): 시작점이 지반 위면 성토부선, 지반 아래(매몰)면
// **0도 절토선**을 원지반 교차까지 연장한다(교차가 없는 자리는 placeable이 막는다).
let addable = false;
if (groundAt(src.offset) < src.elevation - 0.01) {
const tail = trailingFill(src, outward, groundAt);
segments.push(tail);
addable = tail.overLimit;
} else {
const cross = horizontalCrossing(src, outward, groundAt, limitOffset);
if (cross !== null) {
segments.push({
points: [src, { offset: cross, elevation: src.elevation }],
lengthM: Math.abs(cross - src.offset),
overLimit: false,
kind: "cut",
});
}
}
return { walls, segments, appliedAdjusts, addable };
};
if (!input.equalize) return cascadeOnce("user");
// 등간격(2026-08-22 ①) = **벽 사이 사면 구간**을 같게(끝 성토부는 지형이 정하는
// 값이라 제외). 다단은 아래 단 바닥이 깊어질수록 뒷구간이 줄어드는 연쇄라 1회
// 분할로는 안 맞는다 — 현 자리에서 구간 오차를 d로 되먹임하며 수렴시킨다.
let result = cascadeOnce("user");
const horizontalPerSlope = FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN);
for (let round = 0; round < 8; round += 1) {
const count = result.walls.length;
if (count < 2) break; // 한 단이면 등간격 개념이 없다
const lengths = result.segments.slice(0, count).map((segment) => segment.lengthM);
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
if (Math.max(...lengths.map((l) => Math.abs(l - mean))) < 0.05) break;
const dNext = result.appliedAdjusts.map(
(applied, i) => applied.d + (mean - (lengths[i] ?? mean)) * horizontalPerSlope,
);
result = cascadeOnce(dNext);
}
return result;
}