Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts
T
eomsangdonandClaude Opus 5 8dfa68df32 fix(b06,b08): 높이 안 적은 다단 벽은 성토를 안 깎음 — 면적은 첫 미확정 단부터 아래를 없는 것으로 봄 · B08 도 그 아랫단을 「윗단(N단)이 미확정」 으로 같은 선에서 끊음(㉠)
258.12 실측 — 1단만 확정: 성토 감소 −60.36 → −52.50㎥(2단 몫 7.86㎥ 되살아남, +35,534원) · 1단 미확정: 성토·본공사비 다단 없을 때와 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 14:08:49 +09:00

779 lines
35 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_Geom.ts
* 배수관 세트(배관·기슭막이·성토부) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
* 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 기슭막이는 전면
* 1:n 평행사변형(n = 품셈 표준경사 · `leanOf`), 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다
* (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`.
* ========================================================================== */
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
import {
BASIN_MAX_FILL_SLOPE_M,
materialLabel,
revetWallSpec,
PIPE_WALL_DEFAULT_RUN_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
MIN_PIPE_COVER_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
REVET_TRAP_TOP_M,
revetHeightLimit,
revetTargetHeight,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { leanOf } from "./B06_Section_UI_Cross_Lean";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
BasinAdjust,
CulvertLayout,
EndFace,
InletStructureChoice,
OffsetPoint,
WallAdjust,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { DEFAULT_BASIN_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
// 상수·자료형은 분리 파일에 있고, 기존 import 경로를 유지하기 위해 그대로 다시 내보낸다.
export * from "./B06_Section_UI_Cross_Culvert_Const";
export * from "./B06_Section_UI_Cross_Culvert_Types";
import {
applyBasinPipeFill,
attachBasin,
clampBasinMove,
BASIN_INNER_WIDTH_M,
inletChoiceAvailability,
resolveBasinChoice,
} from "./B06_Section_UI_Cross_Culvert_Basin";
import type { OutletExtrasResult } from "./B06_Section_UI_Cross_Culvert_Extra";
import { buildExtrasAt, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
import {
cutLength,
fillWallBaseWidth,
outletSlopeFactory,
fillSlopeOf,
groundInterpolator,
designInterpolator,
slopeToeOffset,
pipeAxisSolver,
placeInletWall,
placePipeWall,
slopeLengthAlong,
} from "./B06_Section_UI_Cross_Culvert_Solve";
/** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */
export function computeCulvertLayout(
section: CrossSection,
groundSamples: SectionSample[],
/** 사용자 조작값(좌우 x·상하 d·높이 h·재질 m — 2026-08-22 4축). 없으면 자동. */
revetShift?: {
inlet?: WallAdjust;
outlet?: WallAdjust;
extras?: WallAdjust[];
basinExtras?: WallAdjust[];
},
inletStructure?: InletStructureChoice,
basinAdjustment?: BasinAdjust,
equalizeExtras?: boolean,
/** 내부용 — 집수정 이동 한계 탐색 재귀에서 다시 클램프하지 않게 막는다. */
skipBasinClamp?: boolean,
): CulvertLayout | null {
const culvert = section.culvert;
if (!culvert) return null;
const groundAt = groundInterpolator(groundSamples);
if (!groundAt) return null;
const edges = section.design?.road_edges;
if (!edges) return null;
const designAt = designInterpolator(section.design?.design_line);
const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0);
const minSample = Math.min(...sampleOffsets);
const maxSample = Math.max(...sampleOffsets);
if (!(maxSample > minSample)) return null;
// I형 집수정 이동 한계 — 관 길이가 바뀌는 지점 직전까지(판정은 Basin이 맡는다).
const probeBasin = (candidate: BasinAdjust) => {
const p = computeCulvertLayout(
section,
groundSamples,
revetShift,
inletStructure,
candidate,
false,
true,
);
return p ? { shape: p.basin?.shape ?? null, lengthM: p.pipe.lengthM } : null;
};
const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST;
const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin);
// 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백).
const uphill = section.uphill_side ?? "left";
const sideInfo = (side: "left" | "right") => ({
edge: side === "left" ? edges.left : edges.right,
outward: side === "left" ? 1 : -1,
limit: side === "left" ? maxSample : minSample,
});
const inletInfo = sideInfo(uphill === "left" ? "left" : "right");
const outletInfo = sideInfo(uphill === "left" ? "right" : "left");
// 유입 invert: 노견 아래 원지반. 상한 = 노면 − 관경 − 토피(B05 하향 차단식과 동일).
const diameter = culvert.diameter_m;
const invertCap = (edge: { offset_m: number; elevation_m: number }): number =>
(designAt ? designAt(edge.offset_m) : edge.elevation_m) - diameter - MIN_PIPE_COVER_M;
const inletInvert = Math.min(groundAt(inletInfo.edge.offset_m), invertCap(inletInfo.edge));
const inlet: OffsetPoint = { offset: inletInfo.edge.offset_m, elevation: inletInvert };
// 집수정 판정: 유입 성토사면 ≤3m면 기본(절토측 포함).
const mode = section.design?.section_mode;
const inletSideName: "left" | "right" = uphill === "left" ? "left" : "right";
const inletIsCut =
mode === "both_cut" ||
(mode === "left_cut" && inletSideName === "left") ||
(mode === "right_cut" && inletSideName === "right");
const inletFillSlopeLen =
!inletIsCut && designAt
? slopeLengthAlong(
designAt,
inletInfo.edge.offset_m,
slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit),
)
: 0;
const ruleReason: BasinLayout["reason"] | null = inletIsCut
? "cut"
: culvert.inlet.structure === "집수정"
? "cut"
: inletFillSlopeLen <= BASIN_MAX_FILL_SLOPE_M + 1e-6
? "short"
: null;
const choice: InletStructureChoice = inletStructure ?? "auto";
// 독립 기슭막이(관 숨김)는 집수정을 세우지 않는다 — 설치 측이 상단(유입 역할)일 때만
// 집수정이 서서 좌·우가 갈리던 원인(2026-08-29 사용자: 양쪽 다 성토 로직).
const { shape: basinShape, reason: choiceReason } = resolveBasinChoice(choice, ruleReason);
const basinReason = culvert.hidden_pipe ? null : choiceReason;
// ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치 — 바닥(=관 invert)이 따라 오른다.
if (basinReason && basinShape !== "I") {
inlet.elevation = inletInfo.edge.elevation_m - basinAdjust.innerHeightM;
} else if (basinReason) {
// I형 — 관 시작점(내공 1.0m 자리) invert = 그 x의 원지반(토피 상한 이내).
const startOffset =
inlet.offset + inletInfo.outward * (BASIN_INNER_WIDTH_M + basinAdjust.lateralM);
inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge));
}
// 벽 제원(4축): 재질이 높이 한계(메2/찰3/콘5)를 정한다. 높이 기준 = **순수 높이**
// (바닥~상단 — 2026-08-23 사용자). 하한·기본 = 관경 + 아래 0.5 + 위 0.5 —
// 관이 벽 위로 삐져나오거나 바닥에 붙으면 안 된다. 기하는 종전대로
// invert~상단 계산용 높이(= 순수 − 0.5)로 돈다.
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
const adjInlet = adjustOf(revetShift?.inlet);
const adjOutlet = adjustOf(revetShift?.outlet);
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5).
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
const lean = leanOf({
form: wall.form,
height_m: wall.pureHeight,
section_mode: section.design?.section_mode,
side: culvert.hidden_pipe ? spec.face_side : null,
face_role: spec.face_role,
face_slope_ratio: spec.face_slope_ratio,
});
return { ...wall, lean };
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 조정창 선택지 가용성 — 판정은 Basin이 맡는다.
const inletOptions = inletChoiceAvailability({
designAt,
groundAt,
edge: inletInfo.edge,
outward: inletInfo.outward,
limitOffset: inletInfo.limit,
wallHeight: inletWallSpec.height,
invertCapM: invertCap(inletInfo.edge),
diameterM: diameter,
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
});
/** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다(높이는 순수 높이 기준). */
const appliedAdjust = {
inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.pureHeight : null },
outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.pureHeight : null },
};
/** 손으로 민 벽의 수직 제원(높이 증가·바닥 띄움) — buildWall에 물려 준다. */
const wallVertical: { inlet: WallVertical | null; outlet: WallVertical | null } = {
inlet: null,
outlet: null,
};
// d의 0점 = **성토선 길이 0 지점**(벽 상단 = 노견 — 2026-08-23 사용자 확정).
// 그 자리(하단선 중점)는 이음선 상단점이 노견 끝에 오는 offset이고, 기본 자리는
// 성토선을 타고 경사길이 4m 내려간 지점(PIPE_WALL_DEFAULT_RUN_M)이다.
const slopeZeroAnchor = (
edge: { offset_m: number },
outward: number,
wallHeight: number,
lean: number,
): number =>
edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M);
if (!basinReason && designAt) {
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라
// 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard).
const placed = placeInletWall({
autoOffset: slopeZeroAnchor(
inletInfo.edge,
inletInfo.outward,
inletWallSpec.height,
inletWallSpec.lean,
),
outward: inletInfo.outward,
height: inletWallSpec.height,
baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height,
edgeElevation: inletInfo.edge.elevation_m,
// 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게
// 오르내린다(관이 없어 유출 수용 검사 outletGuard도 뺀다 — 위 이동을 막던 원인).
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : invertCap(inletInfo.edge),
adjust: adjInlet,
defaultD: PIPE_WALL_DEFAULT_RUN_M,
groundAt,
limitOffset: inletInfo.limit,
// 독립 기슭막이는 유출 벽과 같은 자리 규칙 — 매몰-무교차 자리 금지(2026-08-29).
requireCrossing: culvert.hidden_pipe === true,
outletGuard: culvert.hidden_pipe
? undefined
: {
edge: outletInfo.edge,
outward: outletInfo.outward,
height: outletWallSpec.height,
toeOffset: slopeToeOffset(
designAt,
groundAt,
outletInfo.edge.offset_m,
outletInfo.limit,
),
limitOffset: outletInfo.limit,
lean: outletWallSpec.lean,
},
});
appliedAdjust.inlet.x = placed.x;
appliedAdjust.inlet.d = placed.d;
wallVertical.inlet = {
height: inletWallSpec.height,
baseElevation: placed.invert,
floatGapM: Math.max(0, placed.invert - inletBaseAt(placed.anchorOffset)),
};
inlet.offset = placed.anchorOffset;
inlet.elevation = placed.invert;
}
// 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2).
// 그 자리가 유출 기슭막이 자리이고, invert는 원지반(관 끝이 원지반 위 — 실무 도면).
const outletAnchorOffset = designAt
? slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit)
: outletInfo.edge.offset_m;
// 성토사면 경사길이 실측(노견 → 사면 끝) — 법정 5m 이내 충족 확인용(사용자 ①).
const fillSlopeLength = designAt
? slopeLengthAlong(designAt, outletInfo.edge.offset_m, outletAnchorOffset)
: 0;
// 역경사는 수평으로 클램프(수평 가능 — 사용자 확정).
const outletInvert = Math.min(groundAt(outletAnchorOffset), inlet.elevation);
const outletAnchor: OffsetPoint = { offset: outletAnchorOffset, elevation: outletInvert };
const run0 = outletAnchor.offset - inlet.offset;
const rise0 = outletAnchor.elevation - inlet.elevation;
const length0 = Math.hypot(run0, rise0);
if (!(length0 > 0.5)) return null;
// 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(2026-08-20 사용자 확정).
let lengthM = Math.ceil(length0 - 1e-6);
const scale = lengthM / length0;
const outlet: OffsetPoint = {
offset: inlet.offset + run0 * scale,
elevation: inlet.elevation + rise0 * scale,
};
const span = Math.abs(outletAnchor.offset - inlet.offset);
let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0;
// 기슭막이 합성 단면: 하부 사다리꼴(배면 수직·전면 1:0.3) + 상부 평행사변형 띠.
const walls: WallLayout[] = [];
let basin: BasinLayout | null = null;
/** 집수정이 서면 관 유입단을 내공 안으로 옮긴다(접속 표현). */
let basinPipeEnd: OffsetPoint | null = null;
/** 집수정 계류측 성토부 시작점·바닥(ㄴ·ㄷ형이 원지반 위로 나올 때만). */
let basinFillStart: OffsetPoint | null = null;
let basinFillBottom = 0;
/** 관 끝단 마감면 — 구조물 계류측 변 복사(2026-08-20 사용자 ③). */
const endFaces: { inlet: EndFace | null; outlet: EndFace | null } = {
inlet: null,
outlet: null,
};
let trimMin = Number.NEGATIVE_INFINITY;
let trimMax = Number.POSITIVE_INFINITY;
// 트림 경계에서 사면선 끝이 닿아야 할 표고(= 벽 이음선 상단점 — 2026-08-21 사용자 ②).
let trimMinElevation: number | null = null;
let trimMaxElevation: number | null = null;
/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도). 설계선 대신 그린다. */
let trimMinSlope: { points: OffsetPoint[] } | null = null;
let trimMaxSlope: { points: OffsetPoint[] } | null = null;
const buildWall = (
spec: CulvertSideSpec,
anchor: OffsetPoint,
outward: number,
forceBasinReason: BasinLayout["reason"] | null,
thickness: number = REVET_THICKNESS_M,
vertical: WallVertical | null = null,
material: RevetMaterial = "dry",
formLabel: string | null = null,
lean: number = REVET_LEAN_RATIO,
): WallLayout | null => {
// spec의 "집수정"은 ruleReason에 이미 반영 — 여기서 되살리면 revet 선택이 깨진다.
const reason: BasinLayout["reason"] | null = forceBasinReason;
if (reason) {
const built = attachBasin({
anchor,
outward,
shape: basinShape,
reason,
diameterM: diameter,
edge: spec.role === "inlet" ? inletInfo.edge : outletInfo.edge,
groundAt,
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
adjust: basinAdjust,
});
basin = built.basin;
basinPipeEnd = built.pipeEnd;
basinFillStart = built.fillStart;
basinFillBottom = built.fillBottomElevation;
endFaces[spec.role] = built.endFace;
// 사면선은 벽 상단 도로측 꼭짓점에서 끊고 끝단 표고도 거기 맞춘다.
if (outward > 0) {
trimMax = built.trimOffset;
trimMaxElevation = built.trimElevation;
if (built.approach) trimMaxSlope = built.approach;
} else {
trimMin = built.trimOffset;
trimMinElevation = built.trimElevation;
if (built.approach) trimMinSlope = built.approach;
}
return null;
}
// 형상: 배면 수직 + 계류측 1:n 평행사변형 띠(n = 표준경사). 높이는 vertical(계산용)이 들고 온다.
const height =
vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
const floatGapM = vertical?.floatGapM ?? 0;
// 자리 기준 = **하단선 중점**(2026-08-21) — anchor.elevation은 그 자리 관 invert.
const baseWidth = thickness * 1.5 + lean * 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 };
// 상단 변: 사다리꼴 상단(t/2) + 띠(t). 이음선은 상단 변 중간점에서 1:0.3으로 바닥까지.
const topFront = topJoint + outward * thickness;
// 하단선 = **수평 기초**, 깊이 = 기준선(관 invert) 아래 근입 0.5m 고정 —
// 원지반에 묻혀도 바닥을 더 내리지 않는다. 벽 형상은 높이값으로만 정한다
// (2026-08-23 사용자 확정 — 종전 전면 발끝 지반 추적 수렴 삭제).
const frontXAt = (elevation: number): number =>
topFront + outward * lean * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
offset: frontXAt(bottomElevation),
elevation: bottomElevation,
};
const wall: WallLayout = {
role: spec.role,
form: formLabel ?? materialLabel(material),
lengthM: spec.revet_length_m ?? null,
backOffset,
outerOffset: bottomFront.offset,
base: anchor.elevation,
// 높이 = **계산용 높이(관 invert~상단, 근입 제외)로 통일**(2026-08-22 사용자
// 확정 — 기초는 시공 시 묻히는 부분이라 높이·한계 검사 모두 이 기준).
height,
floatGapM,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
bottomBack,
bottomFront,
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
};
walls.push(wall);
// 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ②).
if (outward > 0) {
trimMax = topJoint;
trimMaxElevation = topElevation;
} else {
trimMin = topJoint;
trimMinElevation = topElevation;
}
// 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③).
endFaces[spec.role] = {
base: bottomFront,
direction: {
offset: topFront - bottomFront.offset,
elevation: topElevation - bottomFront.elevation,
},
};
return wall;
};
const inletWall = buildWall(
culvert.inlet,
inlet,
inletInfo.outward,
basinReason,
REVET_THICKNESS_M,
wallVertical.inlet,
inletWallSpec.material,
inletWallSpec.form,
inletWallSpec.lean,
);
// 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21).
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
let outletWallAnchor = outletAnchor;
// 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 경사길이 4m 지점. 관 길이
// 정수 맞춤 루프가 벽을 옮길 때 invert가 같은 성토선을 타도록 함수로 둔다.
const outletZeroAnchor = slopeZeroAnchor(
outletInfo.edge,
outletInfo.outward,
outletWallSpec.height,
outletWallSpec.lean,
);
const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height;
const outletLineInvertAt = (offset: number): number =>
designAt
? outletZeroInvert - ((offset - outletZeroAnchor) * outletInfo.outward) / FILL_SLOPE_RATIO_MIN
: invertAt(offset);
if (designAt) {
// 4축 배치(유입과 동일 풀이) + 유출 전용: 매몰-무교차 자리 금지(2026-08-22 ③).
const placed = placePipeWall({
autoOffset: outletZeroAnchor,
outward: outletInfo.outward,
height: outletWallSpec.height,
baseElevation0: outletZeroInvert,
edgeElevation: outletInfo.edge.elevation_m,
// 독립 기슭막이는 양쪽 벽이 독립 — 유출을 유입 invert로 묶는 역경사 클램프를 뺀다.
invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : inlet.elevation,
adjust: adjOutlet,
defaultD: PIPE_WALL_DEFAULT_RUN_M,
groundAt,
limitOffset: outletInfo.limit,
requireCrossing: true,
});
appliedAdjust.outlet.x = placed.x;
appliedAdjust.outlet.d = placed.d;
wallVertical.outlet = {
height: outletWallSpec.height,
baseElevation: placed.invert,
floatGapM: Math.max(0, placed.invert - invertAt(placed.anchorOffset)),
};
outletWallAnchor = { offset: placed.anchorOffset, elevation: placed.invert };
}
let outletWall = buildWall(
culvert.outlet,
outletWallAnchor,
outletInfo.outward,
null,
REVET_THICKNESS_M,
wallVertical.outlet,
outletWallSpec.material,
outletWallSpec.form,
outletWallSpec.lean,
);
// ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다.
const pipeStart = basinPipeEnd ?? inlet;
/** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의
* 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */
const outletPipeEnd = (wall: WallLayout): OffsetPoint => {
// 기준 = 바닥 +0.5(= 관 invert 자리 — 2026-08-23부터 바닥은 invert0.5 고정).
const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
return {
offset:
wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - reference),
elevation: reference,
};
};
// 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은
// 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로.
if (outletWall) {
const face = outletPipeEnd(outletWall);
const runW = face.offset - pipeStart.offset;
const riseW = face.elevation - pipeStart.elevation;
const lenW = Math.hypot(runW, riseW);
if (lenW > 0.5) {
const scaleW = Math.ceil(lenW - 1e-6) / lenW;
outlet.offset = pipeStart.offset + runW * scaleW;
outlet.elevation = pipeStart.elevation + riseW * scaleW;
lengthM = Math.ceil(lenW - 1e-6);
}
}
// ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질
// 때마다 다시 유도한다(2026-08-20 사용자 ③).
const pipeAxis = pipeAxisSolver(pipeStart, outlet, diameter, endFaces);
const deriveAxis = pipeAxis.derive;
const endCorners = pipeAxis.corners;
let pipeCorners = {
inlet: endCorners("inlet", pipeStart),
outlet: endCorners("outlet", outlet),
};
// ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출
// 벽 자리를 관 축 방향 바깥으로 민다(벽 두께 0.45 고정).
// 정수 맞춤이 옮길 후보 자리도 같은 성토선을 탄다(2026-08-23 — 0점 기준 통일).
const outletSlopeAt = outletSlopeFactory(outletInfo, outletLineInvertAt, outletWallSpec.height);
/** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */
const outletThickness = REVET_THICKNESS_M;
// 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다.
// d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값).
const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null;
if (outletWall && !outletPinned) {
const target = Math.ceil(cutLength(pipeCorners) - 1e-6);
const rebuild = (): void => {
walls.pop();
outletWall = buildWall(
culvert.outlet,
outletWallAnchor,
outletInfo.outward,
null,
outletThickness,
{
height: outletWallSpec.height,
baseElevation: outletWallAnchor.elevation,
floatGapM: Math.max(0, outletWallAnchor.elevation - invertAt(outletWallAnchor.offset)),
},
outletWallSpec.material,
outletWallSpec.form,
outletWallSpec.lean,
);
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor;
const runW = face.offset - pipeStart.offset;
const riseW = face.elevation - pipeStart.elevation;
const lenW = Math.hypot(runW, riseW) || 1;
outlet.offset = pipeStart.offset + (runW / lenW) * target;
outlet.elevation = pipeStart.elevation + (riseW / lenW) * target;
deriveAxis();
pipeCorners = {
inlet: endCorners("inlet", pipeStart),
outlet: endCorners("outlet", outlet),
};
};
for (let pass = 0; pass < 8; pass += 1) {
const gap = target - cutLength(pipeCorners);
if (Math.abs(gap) < 0.005) break;
// ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위).
const moved = outletWallAnchor.offset + pipeAxis.axis.offset * gap;
const movedSlope = outletSlopeAt(moved, outletThickness);
// 사면 역전(벽 상단이 노견 위)·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다
// 도면 성립이 먼저다. 성토고 0(성토선 0점)까지는 허용(2026-08-23 사용자).
if (
(moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 ||
outletInfo.edge.elevation_m - (outletLineInvertAt(moved) + outletWallSpec.height) < 0
) {
break;
}
// 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 —
// 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적).
if (
movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 &&
movedSlope.ratio <= FILL_SLOPE_RATIO_MAX &&
movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6
) {
outletWallAnchor = { offset: moved, elevation: outletLineInvertAt(moved) };
rebuild();
if (!outletWall) break;
continue;
}
// ② 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다).
break;
}
}
if (outletWall) {
// 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다).
// 수동 벽으로 실길이가 정수가 아니어도 표기만 올림한다(2026-08-22 사용자 확정).
lengthM = Math.max(1, Math.ceil(cutLength(pipeCorners) - 1e-6));
slopePct =
Math.abs(outlet.offset - pipeStart.offset) > 1e-9
? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) *
100
: 0;
}
// I형 집수정: 관 하단 꼭짓점이 지반 위에 뜨면 3도 되메움선(Basin 분리).
if (basin) {
applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt);
}
// 유입 기슭막이의 관 시작 접속선 — 위 0도 성토선 / 아래 0도 1m 공간 확보에서 끝
// (2026-08-23 사용자 — 절토 경사 연장 삭제).
const inletFill =
!basin && !culvert.hidden_pipe
? inletGroundConnector(
pipeCorners.inlet.bottom,
inletInfo.outward,
groundAt,
section.design?.cut_slope_ratio ?? 1.0,
inletInfo.limit,
)
: null;
// 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈).
const extraLean = (form: string, pureHeightM: number): number =>
leanOf({
form,
height_m: pureHeightM,
section_mode: section.design?.section_mode,
side: culvert.hidden_pipe ? culvert.inlet.face_side : null,
});
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
const outletStart = outletWall ? pipeCorners.outlet.bottom : null;
const outletInput = {
startBottomElevation: outletWall?.bottomBack.elevation ?? 0,
outward: outletInfo.outward,
groundAt,
limitOffset: outletInfo.limit,
adjusts: (revetShift?.extras ?? []).map(adjustOf),
ownerForm: outletWallSpec.form,
equalize: equalizeExtras === true,
leanFor: extraLean,
};
const extras = buildExtrasAt(outletStart, outletInput);
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
const basinStart = culvert.hidden_pipe
? inletWall
? pipeCorners.inlet.bottom
: null
: basinFillStart;
const basinInput = {
startBottomElevation: culvert.hidden_pipe
? (inletWall?.bottomBack.elevation ?? 0)
: basinFillBottom,
outward: inletInfo.outward,
groundAt,
limitOffset: inletInfo.limit,
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
ownerForm: inletWallSpec.form,
equalize: false,
leanFor: extraLean,
};
const basinExtras = buildExtrasAt(basinStart, basinInput);
/** ⭐ 면적용 다단 — **미확정 단(높이 안 적음)부터 아래는 없는 것**으로 봄(2026-09-14 브레인 판정
* 「미확정 벽은 성토도 안 깎음 · 벽 금액과 성토 감소가 함께」). 그림은 그대로 다 그림.
* B08 도 첫 미확정 단 아래는 미확정으로 셈(`facility_structures`) — 두 곳이 같은 선에서 끊김. */
const confirmedOnly = (
built: OutletExtrasResult,
start: OffsetPoint | null,
input: typeof outletInput,
): OutletExtrasResult => {
const firstUnset = built.appliedAdjusts.findIndex((applied) => applied.h == null);
if (firstUnset < 0) return built;
return buildExtrasAt(start, {
...input,
adjusts: input.adjusts.slice(0, firstUnset),
equalize: false,
});
};
const extrasForArea = confirmedOnly(extras, outletStart, outletInput);
const basinExtrasForArea = confirmedOnly(basinExtras, basinStart, basinInput);
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
const slopeOf = (wall: WallLayout): FillSlopeSegment =>
fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge);
for (const wall of walls) {
const slope = slopeOf(wall);
// 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의
// 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다).
if (wall.outward > 0) {
trimMax = slope.points[0].offset;
trimMaxElevation = null;
trimMaxSlope = { points: slope.points };
} else {
trimMin = slope.points[0].offset;
trimMinElevation = null;
trimMinSlope = { points: slope.points };
}
}
// 다단 기슭막이의 성토부선까지 트림에 넣는다 — 넣지 않으면 단을 올려도 폐회로가 그대로라
// **물량이 안 바뀐다**(2026-09-07 사용자 확정). 기준벽 사면은 노견~벽 상단까지고, 그
// 바깥은 지금까지 원지반으로 봐서 면적이 0이었다. 화면이 그리는 선(`outletFill.segments`)과
// 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다.
const drawnFillPoints = (result: OutletExtrasResult): OffsetPoint[] =>
result.segments
.filter((segment) => segment.kind !== "cut")
.flatMap((segment) => segment.points);
// ↓ 되돌릴 자리(2026-09-07) — 「단이 2개 이상일 때만 반영」으로 좁히려면 아래 두 줄의
// `drawnFillPoints(extras)` 를 `extras.walls.length ? drawnFillPoints(extras) : []` 로
// 바꾸면 된다(basinExtras 도 같은 꼴). 다만 그러면 화면이 그리는 성토부선과 면적이
// 다시 어긋난다 — 사용자 판단 대기 중인 항목임(PLAN 3-5 ⓑ).
const extendTrimSlope = (points: OffsetPoint[], outward: number): void => {
if (!points.length) return;
if (outward > 0) {
trimMaxSlope = { points: [...(trimMaxSlope?.points ?? []), ...points] };
} else {
trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] };
}
};
extendTrimSlope(drawnFillPoints(extrasForArea), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtrasForArea), inletInfo.outward);
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
return {
inletOptions,
culvert,
pipe: { inlet: pipeStart, outlet, lengthM, slopePct },
pipeAxis: { axis: pipeAxis.axis, normal: pipeAxis.normal },
pipeCorners,
walls,
fillSlope: {
lengthM: outletSlope ? outletSlope.lengthM : fillSlopeLength,
withinLimit: outletSlope
? outletSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6
: fillSlopeLength <= FILL_SLOPE_MAX_LENGTH_M + 1e-6,
ratio: outletSlope
? outletSlope.ratio
: (section.design?.fill_slope_ratio ?? FILL_SLOPE_RATIO_MIN),
ratioClamped: outletSlope ? outletSlope.clamped : false,
roadWideningM: outletSlope ? outletSlope.wideningM : 0,
// 사면길이 5m 이상 = **기슭막이(구조물) 의무 구간**(성토_비탈면.md §2 —
// 2026-08-21 사용자 정정: 5m는 자리 한계가 아니라 의무 발생 기준이다).
structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M,
},
outletFill: { segments: extras.segments, addable: extras.addable },
basinFill: { segments: basinExtras.segments, addable: basinExtras.addable },
basinExtras: basinExtras.walls,
basinAdjust,
inletFill,
extraWalls: extras.walls,
basin,
revetShift: {
inlet: appliedAdjust.inlet,
outlet: appliedAdjust.outlet,
extras: extras.appliedAdjusts,
basinExtras: basinExtras.appliedAdjusts,
},
designTrim:
walls.length || basin
? {
minOffset: trimMin,
maxOffset: trimMax,
...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}),
...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}),
...(trimMinSlope ? { minSlope: trimMinSlope } : {}),
...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}),
}
: null,
};
}