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
779 lines
35 KiB
TypeScript
779 lines
35 KiB
TypeScript
/* =============================================================================
|
||
* 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부터 바닥은 invert−0.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,
|
||
};
|
||
}
|