feat(B06): I형 집수정 이동 한계 = 배관 길이가 바뀌기 직전까지

I형은 관을 감싸는 구조라 집수정을 옮기면 관 시작점이 따라가 관 길이(m 규격)가
바뀐다. 길이가 달라지는 지점 **직전**이 최대 이동이다(2026-08-22 사용자 확정).

- clampBasinMove(Basin): 요청값에서 0.1씩(상하 먼저, 다음 좌우) 되돌려 기준
  길이와 같아지는 자리를 찾는다. ㄴ·ㄷ형은 대상 아님(관이 바닥에서 출발)
- 적용값을 layout.basinAdjust로 돌려주고, Wire가 세션값을 실제 적용값으로
  되돌린 뒤 토스트로 이유를 알린다(눌러도 안 움직이는데 숫자만 커지는 것 방지)
- 700줄 유지: pipeAxisSolver(Solve)로 관 축·끝단 계산 이전, Geom 673줄

검증: 4+4.3 I형 좌우 2m 요청 → 0.7m 적용·관 길이 8m 유지, 13+15.7은 0.4m
적용·14m 유지. 상하 요청도 같은 한계. ㄴ형은 클램프 없이 2m 그대로 적용.
화면에서 ◀ 12회 연속 → 0.7m에서 멈추고 관 L=8.0m 불변, 토스트 안내.
tsc·prettier 통과 · pytest 148 passed, 7 skipped · 700줄 초과 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 19:10:28 +09:00
co-authored by Claude Opus 5
parent 917cdce5df
commit 30031c33c0
6 changed files with 141 additions and 54 deletions
@@ -386,3 +386,27 @@ export function attachBasin(input: BasinBuildInput): BasinAttachResult {
});
return { ...built, approach: points ? { points } : null };
}
/**
* I형 집수정 **이동 한계**(2026-08-22 사용자 확정) — I형은 관을 감싸는 구조라 옮기면
* 관 시작점이 따라가 **관 길이(m 규격)가 바뀐다**. 길이가 바뀌는 지점 직전이 최대다.
* 요청값에서 0.1씩(상하 먼저, 다음 좌우) 되돌려 기준 길이와 같아지는 자리를 찾는다.
* `probe`는 같은 단면을 다시 계산하는 함수(본체가 재귀로 물려 준다).
*/
export function clampBasinMove(
adjust: BasinAdjust,
probe: (candidate: BasinAdjust) => { shape: BasinShape | null; lengthM: number } | null,
): BasinAdjust {
if (adjust.lateralM <= 1e-9 && adjust.slopeM <= 1e-9) return adjust;
const base = probe({ ...adjust, lateralM: 0, slopeM: 0 });
if (base?.shape !== "I") return adjust;
const round1 = (value: number): number => Math.round(value * 10) / 10;
let lateralM = adjust.lateralM;
let slopeM = adjust.slopeM;
for (let pass = 0; pass < 400 && (lateralM > 1e-9 || slopeM > 1e-9); pass += 1) {
if (probe({ ...adjust, lateralM, slopeM })?.lengthM === base.lengthM) break;
if (slopeM > 1e-9) slopeM = Math.max(0, round1(slopeM - 0.1));
else lateralM = Math.max(0, round1(lateralM - 0.1));
}
return { ...adjust, lateralM, slopeM };
}
@@ -1,11 +1,9 @@
/* =============================================================================
* B06_Section_UI_Cross_Culvert_Geom.ts
* 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
* 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 배치 규칙은 실무
* 횡단도 기준(2026-08-20 사용자 제공, 울진 계열): 기슭막이 평행사변형(전면 1:0.3),
* 배관은 두 벽 사이 직선·끝단면은 구조물 변과 평행, 유출측은 관 하단 꼭짓점부터
* 성토부선(보호공 삭제 — 2026-08-22), 성토 경사선은 벽 접점에서 끊는다(designTrim).
* 유입 = 상단측(uphill_side). 집수정 구성은 `_Cross_Culvert_Basin.ts` 참조.
* 배수관 세트(배관·기슭막이·성토부) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
* 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 기슭막이는 전면
* 1:0.3 평행사변형, 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다
* (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`.
* ========================================================================== */
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
@@ -36,7 +34,6 @@ import type {
EndFace,
InletStructureChoice,
OffsetPoint,
PipeEnd,
WallAdjust,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
@@ -49,6 +46,7 @@ export * from "./B06_Section_UI_Cross_Culvert_Types";
import {
applyBasinPipeFill,
attachBasin,
clampBasinMove,
BASIN_INNER_WIDTH_M,
inletChoiceAvailability,
resolveBasinChoice,
@@ -56,19 +54,17 @@ import {
import { buildExtrasAt, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
import {
clampToFace,
cutLength,
minShoulderWallOffset,
outletSlopeFactory,
fillSlopeOf,
groundInterpolator,
designInterpolator,
intersect,
slopeToeOffset,
pipeAxisSolver,
placeInletWall,
placePipeWall,
slopeLengthAlong,
STRAY_LIMIT_M,
} from "./B06_Section_UI_Cross_Culvert_Solve";
/** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */
@@ -85,6 +81,8 @@ export function computeCulvertLayout(
inletStructure?: InletStructureChoice,
basinAdjustment?: BasinAdjust,
equalizeExtras?: boolean,
/** 내부용 — 집수정 이동 한계 탐색 재귀에서 다시 클램프하지 않게 막는다. */
skipBasinClamp?: boolean,
): CulvertLayout | null {
const culvert = section.culvert;
if (!culvert) return null;
@@ -97,7 +95,21 @@ export function computeCulvertLayout(
const minSample = Math.min(...sampleOffsets);
const maxSample = Math.max(...sampleOffsets);
if (!(maxSample > minSample)) return null;
const basinAdjust = basinAdjustment ?? DEFAULT_BASIN_ADJUST;
// 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";
@@ -196,9 +208,8 @@ export function computeCulvertLayout(
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용.
inletAutoOffset = inletOptions.revetAutoOffset;
// 4축 배치는 공용 풀이(placePipeWall — Solve): 좌우 = 노견 연장
// 평행이동, 상하 = 성토선 대각, 위 한계 = 최소 성토고·토피 상한.
// 유입을 내리면 유출도 따라 내려간다 — 유출이 못 받으면 유입도 못 내려간다(가드).
// 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라
// 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard).
const placed = placeInletWall({
autoOffset: inletAutoOffset,
outward: inletInfo.outward,
@@ -473,43 +484,9 @@ export function computeCulvertLayout(
// ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질
// 때마다 다시 유도한다(2026-08-20 사용자 ③).
let axis: OffsetPoint = { offset: 1, elevation: 0 };
let normal: OffsetPoint = { offset: 0, elevation: 1 };
let topAnchor: OffsetPoint = pipeStart;
const deriveAxis = (): void => {
const runP = outlet.offset - pipeStart.offset;
const riseP = outlet.elevation - pipeStart.elevation;
const axisLength = Math.hypot(runP, riseP) || 1;
axis = { offset: runP / axisLength, elevation: riseP / axisLength };
normal = { offset: -axis.elevation, elevation: axis.offset };
if (normal.elevation < 0) normal = { offset: -normal.offset, elevation: -normal.elevation };
topAnchor = {
offset: pipeStart.offset + normal.offset * diameter,
elevation: pipeStart.elevation + normal.elevation * diameter,
};
};
deriveAxis();
const endCorners = (role: "inlet" | "outlet", endPoint: OffsetPoint): PipeEnd => {
const fallback: PipeEnd = {
bottom: endPoint,
top: {
offset: endPoint.offset + normal.offset * diameter,
elevation: endPoint.elevation + normal.elevation * diameter,
},
};
const face = endFaces[role];
if (!face) return fallback;
const bottom = intersect(face.base, face.direction, pipeStart, axis);
const top = intersect(face.base, face.direction, topAnchor, axis);
if (!bottom || !top) return fallback;
const strayed =
Math.hypot(bottom.offset - endPoint.offset, bottom.elevation - endPoint.elevation) >
STRAY_LIMIT_M ||
Math.hypot(top.offset - fallback.top.offset, top.elevation - fallback.top.elevation) >
STRAY_LIMIT_M;
if (strayed) return fallback;
return { bottom: clampToFace(bottom, face), top: clampToFace(top, face) };
};
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),
@@ -556,7 +533,7 @@ export function computeCulvertLayout(
const gap = target - cutLength(pipeCorners);
if (Math.abs(gap) < 0.005) break;
// ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위).
const moved = outletWallAnchor.offset + axis.offset * gap;
const moved = outletWallAnchor.offset + pipeAxis.axis.offset * gap;
const movedSlope = outletSlopeAt(moved, outletThickness);
// 사면 역전·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저다.
if (
@@ -651,7 +628,7 @@ export function computeCulvertLayout(
inletOptions,
culvert,
pipe: { inlet: pipeStart, outlet, lengthM, slopePct },
pipeAxis: { axis, normal },
pipeAxis: { axis: pipeAxis.axis, normal: pipeAxis.normal },
pipeCorners,
walls,
fillSlope: {
@@ -671,6 +648,7 @@ export function computeCulvertLayout(
outletFill: { segments: extras.segments, addable: extras.addable },
basinFill: { segments: basinExtras.segments, addable: basinExtras.addable },
basinExtras: basinExtras.walls,
basinAdjust,
inletFill,
extraWalls: extras.walls,
basin,
@@ -15,7 +15,12 @@ import {
REVET_THICKNESS_M,
REVET_TRAP_TOP_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { OffsetPoint, PipeEnd, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
EndFace,
OffsetPoint,
PipeEnd,
WallAdjust,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */
@@ -492,3 +497,64 @@ export function placeInletWall(input: {
}
return placed;
}
/** 관 축·법선·양 끝단 꼭짓점을 한 번에 푸는 도구(본체 700줄 제한 — 2026-08-22). */
export interface PipeAxisSolver {
axis: OffsetPoint;
normal: OffsetPoint;
/** 벽이 옮겨진 뒤 축을 다시 유도한다. */
derive: () => void;
/** 끝단면(구조물 계류측 변)으로 자른 꼭짓점 — 면이 없거나 크게 벗어나면 폴백. */
corners: (role: "inlet" | "outlet", endPoint: OffsetPoint) => PipeEnd;
}
export function pipeAxisSolver(
pipeStart: OffsetPoint,
outlet: OffsetPoint,
diameter: number,
endFaces: { inlet: EndFace | null; outlet: EndFace | null },
): PipeAxisSolver {
const solver: PipeAxisSolver = {
axis: { offset: 1, elevation: 0 },
normal: { offset: 0, elevation: 1 },
derive: () => undefined,
corners: () => ({ bottom: pipeStart, top: pipeStart }),
};
let topAnchor: OffsetPoint = pipeStart;
solver.derive = (): void => {
const runP = outlet.offset - pipeStart.offset;
const riseP = outlet.elevation - pipeStart.elevation;
const axisLength = Math.hypot(runP, riseP) || 1;
solver.axis = { offset: runP / axisLength, elevation: riseP / axisLength };
const normal = { offset: -solver.axis.elevation, elevation: solver.axis.offset };
solver.normal =
normal.elevation < 0 ? { offset: -normal.offset, elevation: -normal.elevation } : normal;
topAnchor = {
offset: pipeStart.offset + solver.normal.offset * diameter,
elevation: pipeStart.elevation + solver.normal.elevation * diameter,
};
};
solver.corners = (role, endPoint): PipeEnd => {
const fallback: PipeEnd = {
bottom: endPoint,
top: {
offset: endPoint.offset + solver.normal.offset * diameter,
elevation: endPoint.elevation + solver.normal.elevation * diameter,
},
};
const face = endFaces[role];
if (!face) return fallback;
const bottom = intersect(face.base, face.direction, pipeStart, solver.axis);
const top = intersect(face.base, face.direction, topAnchor, solver.axis);
if (!bottom || !top) return fallback;
const strayed =
Math.hypot(bottom.offset - endPoint.offset, bottom.elevation - endPoint.elevation) >
STRAY_LIMIT_M ||
Math.hypot(top.offset - fallback.top.offset, top.elevation - fallback.top.elevation) >
STRAY_LIMIT_M;
if (strayed) return fallback;
return { bottom: clampToFace(bottom, face), top: clampToFace(top, face) };
};
solver.derive();
return solver;
}
@@ -178,6 +178,8 @@ export interface CulvertLayout {
basinFill: { segments: OutletFillSegment[]; addable: boolean };
/** 집수정 계류측 다단 기슭막이 — 조정창 키 `bextra{n}`. */
basinExtras: WallLayout[];
/** 한계에 잘린 뒤의 **실제 적용된** 집수정 조작값 — 조정창이 되받는다. */
basinAdjust: BasinAdjust;
/** 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위면 0도 성토선(지반 정지),
* 아래면 0도 1m 후 표준 절토 경사선. 집수정·접속 불필요면 null. */
inletFill: OutletFillSegment | null;
@@ -89,6 +89,19 @@ export function computeCardCulvert(
equalizeExtras,
);
if (!layout) return null;
// I형 집수정 이동 한계 — 기하가 관 길이가 바뀌지 않는 자리로 잘라 돌려준다.
// 잘렸으면 저장값을 실제 적용값으로 되돌리고(숫자만 커지는 것 방지) 이유를 알린다.
if (inletStructure) {
const requested = inletStructure.adjustFor(section);
const applied = layout.basinAdjust;
const moved =
Math.abs(requested.lateralM - applied.lateralM) > 0.05 ||
Math.abs(requested.slopeM - applied.slopeM) > 0.05;
if (moved) {
if (activeRevet === "inlet") showToast(L("B06_Cross_Basin_Limit_Pipe"), "info");
inletStructure.updateAdjust(section.chainage_m, applied);
}
}
if (extraWalls && extraWalls.countFor(section, "basin") > layout.basinExtras.length) {
extraWalls.syncCount(section.chainage_m, layout.basinExtras.length, "basin");
}
+4
View File
@@ -275,6 +275,10 @@ export const ui_locales_b2 = {
"{mat} height limit {limit}m — change material to go higher",
],
B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"],
B06_Cross_Basin_Limit_Pipe: [
"여기까지입니다 — 더 옮기면 배관 길이가 달라집니다(I형은 관을 감싸는 구조)",
"Limit reached — moving further changes the pipe length (type I wraps the pipe)",
],
B06_Cross_Mat_Label: ["재질", "Material"],
B06_Cross_Mat_Dry: ["메쌓기", "Dry masonry"],
B06_Cross_Mat_Wet: ["찰쌓기", "Wet masonry"],