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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user