fix(B06): 조정 단위를 관 길이 1m로·벽 형상 불변·사면 역전 차단

지적 3건:
- 우측(유출) 기슭막이만 좌우로 밀 때 단면이 변했다. 0.1m씩 미니 관 길이 정수의
  끝수를 벽 폭이 흡수해 0.45→0.90까지 뚱뚱해졌다. 조정 단위를 **관 길이 1m**로
  바꾸고, 조정창으로 옮긴 벽은 폭을 절대 건드리지 않게 했다. 자리만 옮기면 정수
  길이가 성립하므로 형상이 그대로다(검증: 두께 0.450 고정, L 1m씩 증감).
- 성토 사면이 역전되는 자리가 있었다. 벽 상단이 노견보다 높아지면 사면선이 위로
  올라간다 — 최소 성토고 0.3m(FILL_MIN_RISE_M)를 두고 그 앞에서 멈춘다.
- 이동이 노견을 넘어 도로 반대편까지 갔다. 벽은 제 노견 바깥에만 서게 막았다.

조정창은 "관 길이 8m · 바깥 2.0m"처럼 관 길이를 같이 적는다.
700줄 유지를 위해 clampWallOffset·cutLength·outletSlopeFactory를 _Solve로 옮겼다.

검증: 이동 ±5m 20케이스 역전 0건·두께 고정, 275.71 유출 이동 11↔7m 정상,
계획고 스윕 24케이스 회귀 문제 0. tsc 통과, pytest 148 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 16:50:45 +09:00
co-authored by Claude Opus 5
parent 4eed191d2a
commit 8564e118d6
7 changed files with 148 additions and 61 deletions
@@ -105,3 +105,12 @@ export const FILL_SLOPE_MAX_LENGTH_M = 5.0;
* 끝이 아니라 **사면길이 5m 지점**으로 끌어올리는 판정에 쓴다(2026-08-21 사용자 지시).
*/
export const FILL_STRUCTURE_HEIGHT_M = 3.0;
/**
* 성토 사면이 성립하는 **최소 성토고**(m) — 노견 − 벽 이음선 상단.
*
* 이보다 낮은 자리에 벽을 세우면 사면선이 노견에서 벽 상단으로 **올라가** 역전된다
* (2026-08-21 사용자 지적). 자동 배치는 물매 조건이 이미 막지만, 조정창으로 손수
* 미는 경우는 이 하한으로 막는다.
*/
export const FILL_MIN_RISE_M = 0.3;
@@ -1,20 +1,14 @@
/* =============================================================================
* B06_Section_UI_Cross_Culvert_Geom.ts
* 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
* 백엔드가 만든 `section.culvert` 제원을 좌표로 옮긴다 — 치수를 새로 정하지 않는다.
*
* 백엔드(`B06_Section_Engine_Culvert`)가 `pipe_points.json` 정본 + 레지스트리 기본값으로
* 만든 `section.culvert` 제원을 좌표로 옮기기만 한다 — 여기서 치수를 새로 정하지 않는다.
*
* 배치 규칙 — **실무 횡단도 4장 기준으로 재정립**(2026-08-20 사용자 제공, 울진 계열
* `관매설 Φ800~1000 · 기슭막이 유입 찰/유출 메 H=1.5 · 돌붙임(메붙임) ㎡` 주기):
* · 기슭막이 2기 — **도로면 양 가장자리**(유입 = 절토측 측구부, 유출 = 성토측 노견
* 바깥 = 성토사면 최상단)에 선다. 형상은 사면 쪽으로 **기운 평행사변형**(전면 1:0.3,
* 두께 0.45m), 상단 ≈ 노면고.
* · 배관 — 두 벽 사이(도로 하부)만 직선으로 지난다. 단면 항상 직사각형(축 법선 두께),
* 끝단면은 벽 전면과 평행하게 마감. 수평 가능. 길이 표기는 m 단위 올림.
* · 보호공(돌붙임) — **관 하단 꼭짓점**에서 시작해 성토사면 경사(1:n)를 따라 원지반까지,
* 최소 보호공 길이(낙차고×2)를 채울 때까지 지반을 따라 더 간다.
* · 유입측 — 수평 보호공 없음. 측구 접속은 설계선(표준단면 측구)이 이미 표현한다.
* 배치 규칙 — 실무 횡단도 4장 기준(2026-08-20 사용자 제공, 울진 계열 주기):
* · 기슭막이 2기 — 유입/유출측 성토사면 안. 사면 쪽으로 기운 평행사변형(전면 1:0.3,
* 두께 0.45m). 자리는 사면선이 이음선 상단점을 지나도록 풀어서 정한다.
* · 배관 — 두 벽 사이(도로 하부)만 직선. 끝단면은 벽 전면과 평행하게 마감(수평 가능).
* · 보호공(돌붙임) — 관 하단 꼭짓점에서 성토사면 경사를 따라 원지반까지, 최소 길이
* (낙차고×2)를 채울 때까지 지반을 따라 더 간다. 유입측은 수평 보호공 없음.
* · 성토 경사 — 벽 전면과 교차된 이후는 끊는다(designTrim — 벽·보호공이 대신한다).
*
* 유입 = 상단측(uphill_side), 유출 = 하단측. 유입구가 "집수정"이면 그쪽 기슭막이는
@@ -23,6 +17,7 @@
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
import {
FILL_MIN_RISE_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_STRUCTURE_HEIGHT_M,
FILL_SLOPE_RATIO_MAX,
@@ -50,6 +45,9 @@ export * from "./B06_Section_UI_Cross_Culvert_Const";
export * from "./B06_Section_UI_Cross_Culvert_Types";
import {
clampWallOffset,
cutLength,
outletSlopeFactory,
fillSlopeOf,
groundInterpolator,
designInterpolator,
@@ -117,8 +115,7 @@ export function computeCulvertLayout(
: null;
// 성토측 유입 기슭막이는 노견에 붙여 두지 않는다 — 사면선이 벽 이음선 상단점을 지나야
// 하므로 자리를 풀어서 정한다(2026-08-21 사용자 ②). 집수정(절토·막힘)은 측구부 자리
// 그대로 둔다. 벽 높이는 관경 + 여유고(0.1m 눈금 올림).
// 하므로 자리를 풀어서 정한다(집수정은 측구부 자리 그대로).
const wallHeightFor = (spec: CulvertSideSpec): number =>
Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
let inletPlacement: FillWallPlacement | null = null;
@@ -134,8 +131,17 @@ export function computeCulvertLayout(
slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit),
);
if (inletPlacement) {
// 사용자가 화살표로 민 만큼 자동 자리에서 더 옮긴다(2026-08-21 사용자 ①).
const shifted = inletPlacement.offset + inletInfo.outward * (revetShift?.inlet ?? 0);
// 사용자가 조정창으로 민 만큼 자동 자리에서 더 옮긴다 — 사면 역전 구간은 막는다.
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
const shifted = clampWallOffset(
inletPlacement.offset,
inletPlacement.offset + inletInfo.outward * (revetShift?.inlet ?? 0),
wallHeightFor(culvert.inlet),
inletInfo.edge,
inletBaseAt,
inletInfo.outward,
);
inlet.offset = shifted;
inlet.elevation = Math.min(groundAt(shifted), invertCap(inletInfo.edge));
}
@@ -167,8 +173,7 @@ export function computeCulvertLayout(
const rise0 = outletAnchor.elevation - inlet.elevation;
const length0 = Math.hypot(run0, rise0);
if (!(length0 > 0.5)) return null;
// 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(기슭막이를 조금 벗어나도 무관,
// 2026-08-20 사용자 확정). 실무 표기 L=10~12m대가 여기서 나온다.
// 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(2026-08-20 사용자 확정).
let lengthM = Math.ceil(length0 - 1e-6);
const scale = lengthM / length0;
const outlet: OffsetPoint = {
@@ -346,9 +351,8 @@ export function computeCulvertLayout(
};
return null;
}
// 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): **배면(도로측) 수직**,
// **전면(계류측)은 1:0.3 기운 평행사변형 띠**. 띠 안쪽 평행선과 수직 배면 사이가
// 사다리꼴 — 합집합의 내부 대각 이음선은 배면 상단에서 바닥으로 내려온다.
// 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면
// (계류측)은 1:0.3 기운 평행사변형 띠. 띠 안쪽 평행선과 수직 배면 사이가 사다리꼴로,
// 바닥이 넓고 상단이 좁다(상단 폭 = 띠 두께 0.45).
// 높이: **관경 + 여유고**를 0.1m 눈금으로 올린 값(2026-08-21 사용자 확정). 관이
// 물매를 두어 좌·우 invert가 달라도 **관 상단 위 여유고는 같아야** 한다. 사면선은
@@ -429,7 +433,14 @@ export function computeCulvertLayout(
outletAnchorOffset,
);
if (outletPlacement) {
const shifted = outletPlacement.offset + outletInfo.outward * (revetShift?.outlet ?? 0);
const shifted = clampWallOffset(
outletPlacement.offset,
outletPlacement.offset + outletInfo.outward * (revetShift?.outlet ?? 0),
wallHeightFor(culvert.outlet),
outletInfo.edge,
invertAt,
outletInfo.outward,
);
outletWallAnchor = { offset: shifted, elevation: invertAt(shifted) };
}
}
@@ -490,27 +501,7 @@ export function computeCulvertLayout(
// 길이가 다르다** — 긴 변을 기준으로 올림해 정수 m로 잡고, 모자란 만큼 **유출 벽 자리**
// 를 관 축 방향 바깥으로 민다(벽 두께는 0.45 고정 — 사용자 확정). 벽 상단이 사면선과
// 벌어지는 문제는 설계선 트림이 벽 상단을 따라오므로 생기지 않는다(사용자 ②).
const cutLength = (corners: { inlet: PipeEnd; outlet: PipeEnd }): number =>
Math.max(
Math.hypot(
corners.outlet.bottom.offset - corners.inlet.bottom.offset,
corners.outlet.bottom.elevation - corners.inlet.bottom.elevation,
),
Math.hypot(
corners.outlet.top.offset - corners.inlet.top.offset,
corners.outlet.top.elevation - corners.inlet.top.elevation,
),
);
/** 그 자리에 벽을 세웠을 때의 성토 사면 — 물매(1:n)와 사면길이(노견 → 이음선 상단점). */
const outletSlopeAt = (offset: number, thickness: number): { ratio: number; lengthM: number } => {
const joint = offset + outletInfo.outward * (thickness / 2);
const rise = outletInfo.edge.elevation_m - (invertAt(offset) + wallHeightFor(culvert.outlet));
const run = Math.abs(joint - outletInfo.edge.offset_m);
return {
ratio: rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY,
lengthM: Math.hypot(run, Math.max(rise, 0)),
};
};
const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, wallHeightFor(culvert.outlet));
/** 유출 벽 두께 — 자리 이동만으로 관 길이를 정수로 못 맞출 때만 0.45에서 벗어난다. */
let outletThickness = REVET_THICKNESS_M;
if (outletWall) {
@@ -535,15 +526,25 @@ export function computeCulvertLayout(
const gap = target - cutLength(pipeCorners);
if (Math.abs(gap) < 0.005) break;
// ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위).
// 단 사용자가 화살표로 자리를 지정했으면 그 자리를 지키고 폭으로만 흡수한다.
const pinned = Math.abs(revetShift?.outlet ?? 0) > 1e-9;
const moved = outletWallAnchor.offset + axis.offset * gap;
const movedSlope = outletSlopeAt(moved, outletThickness);
// 사면 역전·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저다.
if (
!pinned &&
movedSlope.ratio >= FILL_SLOPE_RATIO_MIN &&
movedSlope.ratio <= FILL_SLOPE_RATIO_MAX &&
movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6
(moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 ||
outletInfo.edge.elevation_m - (invertAt(moved) + wallHeightFor(culvert.outlet)) <
FILL_MIN_RISE_M
) {
break;
}
// 조정창으로 옮긴 벽은 **폭을 건드리지 않는다** — 폭으로 흡수하면 미는 동안 벽
// 단면이 변한다(2026-08-21 사용자 지적: 유출 벽만 0.45→0.90). 조정이 관 길이 1m
// 단위라 자리만 맞추면 정수가 성립한다. 물매·5m 한계는 자동 배치용 제약이라
// 사용자가 지정한 자리에는 걸지 않는다(경고로 알린다).
if (
Math.abs(revetShift?.outlet ?? 0) > 1e-9 ||
(movedSlope.ratio >= FILL_SLOPE_RATIO_MIN &&
movedSlope.ratio <= FILL_SLOPE_RATIO_MAX &&
movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6)
) {
outletWallAnchor = { offset: moved, elevation: invertAt(moved) };
rebuild();
@@ -633,8 +634,8 @@ export function computeCulvertLayout(
}
}
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
const slopeOf = (wall: WallLayout): FillSlopeSegment =>
fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge);
for (const wall of walls) {
@@ -6,13 +6,14 @@
import type { SectionSample } from "./B06_Section_Api_Fetch";
import {
FILL_MIN_RISE_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
FILL_STRUCTURE_HEIGHT_M,
REVET_TRAP_TOP_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import type { OffsetPoint, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Types";
/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */
export function groundInterpolator(samples: SectionSample[]): ((offset: number) => number) | null {
@@ -219,3 +220,65 @@ export function fillSlopeOf(
to,
};
}
/**
* 손으로 민 벽 자리를 **사면이 역전되지 않는 범위**로 되돌린다(2026-08-21 사용자 지적).
* 노견보다 벽 상단이 높아지면 사면선이 위로 올라가 버린다 — 자동 자리에서 목표 자리로
* 훑으며 성토고가 하한 아래로 떨어지기 직전까지만 간다.
*/
export function clampWallOffset(
autoOffset: number,
wantedOffset: number,
height: number,
edge: { offset_m: number; elevation_m: number },
baseAt: (offset: number) => number,
outward: number,
): number {
const steps = 40;
let allowed = autoOffset;
for (let i = 1; i <= steps; i += 1) {
const candidate = autoOffset + ((wantedOffset - autoOffset) * i) / steps;
// 벽은 제 노견 바깥에 선다 — 안쪽으로 넘기면 도로 밑을 파고들어 반대편까지 간다.
if ((candidate - edge.offset_m) * outward < 0) break;
if (edge.elevation_m - (baseAt(candidate) + height) < FILL_MIN_RISE_M) break;
allowed = candidate;
}
return allowed;
}
/**
* 관 절단 길이(m) — 끝이 기운 벽 전면으로 잘려 **상단·하단 길이가 다르다**.
* 표기·정수 맞춤은 **긴 변** 기준이다(2026-08-21 사용자 확정).
*/
export function cutLength(corners: { inlet: PipeEnd; outlet: PipeEnd }): number {
return Math.max(
Math.hypot(
corners.outlet.bottom.offset - corners.inlet.bottom.offset,
corners.outlet.bottom.elevation - corners.inlet.bottom.elevation,
),
Math.hypot(
corners.outlet.top.offset - corners.inlet.top.offset,
corners.outlet.top.elevation - corners.inlet.top.elevation,
),
);
}
/**
* 유출 벽을 그 자리에 세웠을 때의 성토 사면(물매 1:n·사면길이)을 재는 함수를 만든다.
* 관 길이 정수 맞춤이 벽을 옮겨도 되는 자리인지 판정하는 데 쓴다.
*/
export function outletSlopeFactory(
info: { edge: { offset_m: number; elevation_m: number }; outward: number },
invertAt: (offset: number) => number,
wallHeight: number,
): (offset: number, thickness: number) => { ratio: number; lengthM: number } {
return (offset, thickness) => {
const joint = offset + info.outward * (thickness / 2);
const rise = info.edge.elevation_m - (invertAt(offset) + wallHeight);
const run = Math.abs(joint - info.edge.offset_m);
return {
ratio: rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY,
lengthM: Math.hypot(run, Math.max(rise, 0)),
};
};
}
@@ -21,6 +21,8 @@ export interface StructurePanelDeps {
nudge: (key: RevetKey, screenDeltaM: number) => void;
/** 자동 자리로 되돌린다. */
reset: (key: RevetKey) => void;
/** 지금 관 길이(m) — 조정 단위가 관 길이 1m이라 창에 같이 적는다. */
pipeLengthM: () => number | null;
/** 창을 닫는다 = 구조물 선택 해제. */
close: () => void;
}
@@ -31,8 +33,12 @@ export interface StructurePanelHandle {
show: (key: RevetKey | null) => void;
}
/** 한 걸음 이동량(m) — 벽 높이 눈금(0.1m)과 같은 단위로 맞춘다. */
const STEP_M = 0.1;
/**
* 한 걸음 이동량(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정).
* 관은 m 단위로 설치하므로 0.1m씩 밀면 그 끝수를 벽 폭이 흡수하게 되어 미는 동안
* 벽 단면이 변한다. 한 걸음을 1m로 두면 벽은 자리만 옮기고 형상은 그대로다.
*/
const STEP_M = 1.0;
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
@@ -105,10 +111,13 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
// 자동 자리 기준 이동량 — 0이면 "자동"으로 적어 손을 안 댔음을 바로 알린다.
// 부호(+/−)만 적으면 좌·우 벽에서 어느 쪽인지 읽히지 않아 **안/바깥**으로 적는다.
const direction = L(shift > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward");
value.textContent =
const moved =
Math.abs(shift) < 1e-9
? L("B06_Cross_Revet_Auto")
: `${direction} ${Math.abs(shift).toFixed(1)}m`;
const pipeLength = deps.pipeLengthM();
value.textContent =
pipeLength === null ? moved : `${L("B06_Cross_Revet_Pipe")} ${pipeLength}m · ${moved}`;
},
};
}
+5 -1
View File
@@ -212,6 +212,8 @@ export function createCrossSectionCard(
let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null;
let setRevetActive: RevetHighlightSetter = () => undefined;
let showRevetControl: (visible: boolean) => void = () => undefined;
/** 지금 그린 관 길이(m) — 조정창이 "관 길이 8m"으로 적는다. 배수관 측점이 아니면 null. */
let culvertPipeLengthM: number | null = null;
/**
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
* 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도
@@ -450,7 +452,7 @@ export function createCrossSectionCard(
toggleArea,
);
// 배수관 세트 기하를 먼저 계산한다 — 설계선이 기슭막이 밖 성토 경사를 끊는 데 쓴다.
const culvertLayout = computeCulvertLayout(
const culvertLayout: ReturnType<typeof computeCulvertLayout> = computeCulvertLayout(
section,
sourceSamples,
revetOffset
@@ -481,6 +483,7 @@ export function createCrossSectionCard(
);
}
// 배수관 측점 세트(배관·기슭막이·보호공) — 기존 도형 위에 추가만 한다(2026-08-19).
culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null;
if (culvertLayout) {
setRevetActive = appendCulvertOverlay(
plotLayer,
@@ -571,6 +574,7 @@ export function createCrossSectionCard(
nudge: (key, screenDeltaM) =>
revetOffset?.adjust(section.chainage_m, key, screenDeltaM * outwardOf(key)),
reset: (key) => revetOffset?.reset(section.chainage_m, key),
pipeLengthM: () => culvertPipeLengthM,
close: () => {
if (activeRevet) toggleRevet(activeRevet);
},
@@ -135,9 +135,9 @@ export function createStationControls(deps: StationControlDeps): StationControls
}
}
/** 손으로 미는 범위 한계(m) — 벽이 노면 밑이나 사면 밖으로 달아나지 않게 묶는다. */
/** 손으로 미는 범위 한계(m) — 조정 단위가 관 길이 1m이라 ±10m(=관 10m분)까지 둔다. */
const clampRevetShift = (value: number): number =>
Math.min(Math.max(Math.round(value * 10) / 10, -3), 3);
Math.min(Math.max(Math.round(value * 10) / 10, -10), 10);
const revetOffsetControl: RevetOffsetControl = {
shiftFor: (section, role) => revetShifts.get(revetKey(section.chainage_m, role)) ?? 0,