feat(B06): 기슭막이 자리·하단선 규칙 재정립

2026-08-21 사용자 확정:
- 기본 자리 = **성토 사면이 원지반과 만나는 지점**(사면 끝). 사면이 5m를 넘으면
  기슭막이가 사면을 5m에서 끊는다 — 5m는 자리 한계가 아니라 "5m 이상이면 기슭막이
  의무"라는 규정(성토_비탈면.md §2)의 표현이다. 물매 1:1.2~2.0으로 자리를 풀던
  방식은 걷어냈다(자유도 축소였다).
- 벽 하단선 = **원지반을 수직 0.5m 내린 선**. 수평 바닥이라 사면에서 앞 모서리가
  뜨던 문제가 사라진다(배면·전면 모두 근입 0.500 확인).
- 자리 기준(이동 중심) = **하단선 중점**. 배면 기준이면 벽이 기울 때 체감 위치가
  어긋난다.
- 관 유출단 invert = 그 자리 원지반 → 관 하단 경사가 지반과 만난다.
- structureRequired = 사면길이 5m 이상(의무 발생), 종전의 "받을 수 없는 자리" 판정 대체.

그리기도 기운 하단선에 맞췄다 — 이음선 바닥·돌 해칭이 하단선 위에서 잡힌다.

검증: 두 측점 이동 10케이스 근입 배면·전면 모두 0.500, 하단선 중점이 원지반과 일치
(관 수평 클램프 2건 제외), 계획고 스윕 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 17:56:35 +09:00
co-authored by Claude Opus 5
parent 564b289ee6
commit 38acdd02ed
4 changed files with 81 additions and 169 deletions
+18 -10
View File
@@ -15,7 +15,6 @@ import {
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
REVET_TRAP_TOP_M,
pipeWallThicknessM,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Geom";
@@ -123,10 +122,19 @@ export function appendCulvertOverlay(
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선):
// 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다.
const joint = document.createElementNS(SVG_NS, "line");
joint.setAttribute("x1", String(x(wall.topBack.offset + wall.outward * REVET_TRAP_TOP_M)));
joint.setAttribute("y1", String(toDisplayY(wall.topBack.elevation)));
joint.setAttribute("x2", String(x(wall.outerOffset - wall.outward * REVET_THICKNESS_M)));
joint.setAttribute("y2", String(toDisplayY(wall.base - REVET_EMBED_DEPTH_M)));
// 하단선이 원지반을 따라 기울어 있으므로 이음선 바닥도 하단선 위에서 잡는다.
const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
const bottomSpan = wall.bottomFront.offset - wall.bottomBack.offset;
const bottomAtOffset = (offset: number): number =>
Math.abs(bottomSpan) > 1e-9
? wall.bottomBack.elevation +
(wall.bottomFront.elevation - wall.bottomBack.elevation) *
((offset - wall.bottomBack.offset) / bottomSpan)
: wall.bottomBack.elevation;
joint.setAttribute("x1", String(x(wall.topJoint.offset)));
joint.setAttribute("y1", String(toDisplayY(wall.topJoint.elevation)));
joint.setAttribute("x2", String(x(jointBaseOffset)));
joint.setAttribute("y2", String(toDisplayY(bottomAtOffset(jointBaseOffset))));
joint.setAttribute("class", "b06-chart__culvert-stone");
layer.append(joint);
// 돌 해칭 — 큰 돌이 한 줄로 쌓인 **계류측 기운 띠**(전면 평행사변형)를 따라 쌓는다.
@@ -148,14 +156,14 @@ export function appendCulvertOverlay(
stoneGroup.setAttribute("clip-path", `url(#${clipId})`);
layer.append(clip, stoneGroup);
const embedBase = wall.base - REVET_EMBED_DEPTH_M;
const stoneSpan = wall.height + REVET_EMBED_DEPTH_M;
const centerBaseOffset = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2);
const embedBase = bottomAtOffset(centerBaseOffset);
const stoneSpan = Math.max(0.3, wall.topBack.elevation - embedBase);
const stoneCount = Math.max(2, Math.round(stoneSpan / 0.45));
const stoneHeight = stoneSpan / stoneCount;
// 돌 중심선 = 전면 기운 띠(이음선~전면)의 중심선. 바닥은 근입 바닥까지 연장한다.
const centerBase = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2);
const centerTop =
wall.topBack.offset + wall.outward * (REVET_TRAP_TOP_M + REVET_THICKNESS_M / 2);
const centerBase = centerBaseOffset;
const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2);
// 화면 좌표 기준 기움 각(rect의 세로축을 경사축에 맞춘다).
const axisX = x(centerTop) - x(centerBase);
const axisY = toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase);
@@ -19,7 +19,6 @@ import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section
import {
FILL_MIN_RISE_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_STRUCTURE_HEIGHT_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
MIN_PIPE_COVER_M,
@@ -44,6 +43,7 @@ import type {
export * from "./B06_Section_UI_Cross_Culvert_Const";
export * from "./B06_Section_UI_Cross_Culvert_Types";
import type { FillSlopeSegment } from "./B06_Section_UI_Cross_Culvert_Solve";
import {
clampWallOffset,
cutLength,
@@ -53,10 +53,8 @@ import {
designInterpolator,
intersect,
slopeToeOffset,
solveFillWallOffset,
STRAY_LIMIT_M,
} from "./B06_Section_UI_Cross_Culvert_Solve";
import type { FillSlopeSegment, FillWallPlacement } from "./B06_Section_UI_Cross_Culvert_Solve";
/** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */
export function computeCulvertLayout(
@@ -120,35 +118,25 @@ export function computeCulvertLayout(
Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
/** 요청값이 한계에 걸려 잘린 뒤의 **실제** 이동량. 조정창이 이 값을 되받는다. */
const appliedShift = { inlet: 0, outlet: 0 };
let inletPlacement: FillWallPlacement | null = null;
// 기슭막이 기본 자리 = **성토 사면이 원지반과 만나는 지점**(사면 끝)이고, 그 자리가
// 하단선 중점이 된다(2026-08-21 사용자 확정). 사면길이 5m는 자리 한계가 아니라
// "5m 이상이면 기슭막이 의무"라는 뜻이라 여기서 자르지 않는다.
let inletAutoOffset: number | null = null;
if (!basinReason && designAt) {
// 훑는 범위는 **성토 사면 끝까지**다 — 그 너머는 받칠 성토가 없다. 성토가 얕아 벽
// 상단이 노견보다 높은 구간에서는 물매가 성립하지 않는데, 한계를 지반 샘플 끝까지
// 두면 벽이 몇 m 밖으로 밀려난다(계획고 스윕에서 확인).
inletPlacement = solveFillWallOffset(
inletInfo.edge,
inletInfo.outward,
inletAutoOffset = slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit);
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
const shifted = clampWallOffset(
inletAutoOffset,
inletAutoOffset + inletInfo.outward * (revetShift?.inlet ?? 0),
wallHeightFor(culvert.inlet),
(offset) => Math.min(groundAt(offset), invertCap(inletInfo.edge)),
slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit),
inletInfo.edge,
inletBaseAt,
inletInfo.outward,
);
if (inletPlacement) {
// 사용자가 조정창으로 민 만큼 자동 자리에서 더 옮긴다 — 사면 역전 구간은 막는다.
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,
);
appliedShift.inlet =
Math.round((shifted - inletPlacement.offset) * inletInfo.outward * 10) / 10;
inlet.offset = shifted;
inlet.elevation = Math.min(groundAt(shifted), invertCap(inletInfo.edge));
}
appliedShift.inlet = Math.round((shifted - inletAutoOffset) * inletInfo.outward * 10) / 10;
inlet.offset = shifted;
inlet.elevation = inletBaseAt(shifted);
}
// 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2).
@@ -361,57 +349,55 @@ export function computeCulvertLayout(
// 높이: **관경 + 여유고**를 0.1m 눈금으로 올린 값(2026-08-21 사용자 확정). 관이
// 물매를 두어 좌·우 invert가 달라도 **관 상단 위 여유고는 같아야** 한다. 사면선은
// 높이를 정하지 않고 벽이 설 **자리**만 정한다(`solveFillWallOffset`).
const topJoint = anchor.offset + outward * (thickness / 2);
// 형태별 법정·교본 높이 한계(찰 3.0 / 메 2.0 — 돌쌓기.md §1)를 넘지 못한다.
const height = Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
const topBack: OffsetPoint = {
offset: anchor.offset,
elevation: anchor.elevation + height,
};
// 상단 변: 사다리꼴 상단(0.45) + 띠 상단(0.45). 이음선은 상단 변 중간점(topJoint)
// 에서 1:0.3으로 바닥까지 — 사다리꼴(수직 배면, 상단 0.45 → 하단 0.45+0.3H)과
// 평행사변형 띠(폭 0.45)가 진짜 사다리꼴·평행사변형이 된다.
// **자리 기준 = 하단선 중점**(2026-08-21 사용자 확정). 배면 기준으로 잡으면 벽이
// 기울 때 체감 위치가 어긋난다. anchor.offset이 하단선 중점, anchor.elevation이
// 그 자리 원지반(= 관 invert)이다.
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const frontBase = 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;
const jointBase = topJoint + outward * REVET_LEAN_RATIO * height;
const frontBase = jointBase + outward * thickness;
// 근입: 벽 바닥은 원지반 아래 REVET_EMBED_DEPTH_M만큼 묻힌다(흙막이 "본바닥에
// 기초" 원칙 + 실무 기초콘크리트 H=0.5). 바닥 꼭짓점 2개만 내리고 상단·이음선·
// 관 위치는 그대로 — 관은 여전히 원지반 높이로 지난다.
const embedBase = anchor.elevation - REVET_EMBED_DEPTH_M;
// 근입: 하단선은 **원지반을 수직으로 0.5m 내린 선**이다(2026-08-21 사용자 확정).
// 종전에는 수평 바닥이라 사면에서 앞 모서리가 떴다.
const bottomAt = (offset: number): number => groundAt(offset) - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomAt(backOffset) };
const bottomFront: OffsetPoint = { offset: frontBase, elevation: bottomAt(frontBase) };
const wall: WallLayout = {
role: spec.role,
form: spec.revet_form ?? null,
lengthM: spec.revet_length_m ?? null,
backOffset: anchor.offset,
backOffset,
outerOffset: frontBase,
base: anchor.elevation,
height,
outward,
topBack,
topJoint: { offset: topJoint, elevation: anchor.elevation + height },
points: [
{ offset: anchor.offset, elevation: embedBase },
topBack,
{ offset: topFront, elevation: anchor.elevation + height },
{ offset: frontBase, elevation: embedBase },
],
topJoint: { offset: topJoint, elevation: topElevation },
bottomBack,
bottomFront,
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
};
walls.push(wall);
// 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ②).
if (outward > 0) {
trimMax = topJoint;
trimMaxElevation = anchor.elevation + height;
trimMaxElevation = topElevation;
} else {
trimMin = topJoint;
trimMinElevation = anchor.elevation + height;
trimMinElevation = topElevation;
}
// 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③).
endFaces[spec.role] = {
base: { offset: frontBase, elevation: embedBase },
base: bottomFront,
direction: {
offset: topFront - frontBase,
elevation: anchor.elevation + height - embedBase,
offset: topFront - bottomFront.offset,
elevation: topElevation - bottomFront.elevation,
},
};
return wall;
@@ -425,29 +411,20 @@ export function computeCulvertLayout(
// (2026-08-21 사용자 지적). 관 끝은 원지반 위라는 원칙대로 지반을 따르면 벽이
// 지반에 앉고 관 물매가 그 자리에 맞춰진다. 역경사는 유입 invert로 클램프.
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
// 유출 벽 기본 자리도 **사면 끝**(= outletAnchorOffset)이고 그 자리가 하단선 중점이다.
let outletWallAnchor = outletAnchor;
let outletPlacement: FillWallPlacement | null = null;
const outletAutoOffset = outletAnchorOffset;
if (designAt) {
outletPlacement = solveFillWallOffset(
outletInfo.edge,
outletInfo.outward,
const shifted = clampWallOffset(
outletAutoOffset,
outletAutoOffset + outletInfo.outward * (revetShift?.outlet ?? 0),
wallHeightFor(culvert.outlet),
outletInfo.edge,
invertAt,
outletAnchorOffset,
outletInfo.outward,
);
if (outletPlacement) {
const shifted = clampWallOffset(
outletPlacement.offset,
outletPlacement.offset + outletInfo.outward * (revetShift?.outlet ?? 0),
wallHeightFor(culvert.outlet),
outletInfo.edge,
invertAt,
outletInfo.outward,
);
appliedShift.outlet =
Math.round((shifted - outletPlacement.offset) * outletInfo.outward * 10) / 10;
outletWallAnchor = { offset: shifted, elevation: invertAt(shifted) };
}
appliedShift.outlet = Math.round((shifted - outletAutoOffset) * outletInfo.outward * 10) / 10;
outletWallAnchor = { offset: shifted, elevation: invertAt(shifted) };
}
let outletWall = buildWall(culvert.outlet, outletWallAnchor, outletInfo.outward, null);
// 유출 벽이 사면 끝보다 안쪽으로 당겨졌으면 **관 끝도 그 자리에 맞춘다** — 관만 사면
@@ -660,13 +637,9 @@ export function computeCulvertLayout(
? outletSlope.ratio
: (section.design?.fill_slope_ratio ?? FILL_SLOPE_RATIO_MIN),
ratioClamped: outletSlope ? outletSlope.clamped : false,
// 벽 자리 풀이가 아예 실패했거나(성토고가 사면 5m 안에 안 들어옴), 성토고 3m 이상
// 인데 물매가 1:1.2보다 급하면 기슭막이로는 못 받는다 — 옹벽·석축 검토 대상.
structureRequired:
outletPlacement === null ||
(outletSlope != null &&
outletSlope.fillHeightM >= FILL_STRUCTURE_HEIGHT_M &&
outletSlope.ratio < FILL_SLOPE_RATIO_MIN),
// 사면길이 5m 이상 = **기슭막이(구조물) 의무 구간**(성토_비탈면.md §2 —
// 2026-08-21 사용자 정정: 5m는 자리 한계가 아니라 의무 발생 기준이다).
structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M,
},
pitching: {
top: pitchingTop,
@@ -10,7 +10,6 @@ import {
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, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Types";
@@ -80,7 +79,8 @@ export function intersect(
export const STRAY_LIMIT_M = 2.5;
/** 노견에서 바깥으로 훑어 설계선(사면)이 지반과 만나는 첫 지점(사면 끝)을 찾는다.
* 사면 경사길이 5m 한계(별표2)에 걸리면 그 지점에서 멈춘다 — 거기가 구조물 자리다. */
* 5m는 자리 한계가 아니다 — 사면이 5m 이상이면 **기슭막이가 의무**라는 뜻일 뿐이라
* 여기서 끊지 않는다(2026-08-21 사용자 정정). */
export function slopeToeOffset(
designAt: (offset: number) => number,
groundAt: (offset: number) => number,
@@ -93,6 +93,10 @@ export function slopeToeOffset(
let previousDiff = designAt(startOffset) - groundAt(startOffset);
let previousElevation = designAt(startOffset);
let slopeLength = 0;
// 자리 = **성토선이 지반과 만나는 점**과 **사면길이 5m 지점** 중 안쪽. 사면이 5m를
// 넘으면 기슭막이가 있어야 한다는 규정(성토_비탈면.md §2)은 곧 기슭막이가 사면을
// 5m에서 끊어 준다는 뜻이다. 교차점이 5m 안이면 거기가 자연스러운 사면 끝이다.
// 더 밖으로 두고 싶으면 조정창으로 민다(2026-08-21 사용자 확정).
for (let i = 1; i <= stepCount; i += 1) {
const offset = startOffset + step * i;
const elevation = designAt(offset);
@@ -107,82 +111,6 @@ export function slopeToeOffset(
return limitOffset;
}
/** 성토측 구조물 자리 풀이 결과 — 벽 offset과 그 자리에서 성립하는 사면 물매. */
export interface FillWallPlacement {
offset: number;
/** 사면선이 벽 이음선 상단점을 지나는 물매(1:n). 범위 밖이면 끝값으로 고정된 값. */
ratio: number;
slopeLengthM: number;
withinLimit: boolean;
ratioClamped: boolean;
/** 그 자리의 성토 전체고(노견 − 벽 상단). 3m 이상이면 벽을 5m 쪽으로 끌어올린다. */
fillHeightM: number;
}
/**
* 성토측 기슭막이 자리 풀이 — 노견에서 바깥으로 훑으며 **성토 사면선이 벽 이음선
* 상단점(평행사변형↔사다리꼴 상단 교차점)을 지나는** 자리를 찾는다.
*
* 종전에는 사면선(백엔드 1:1.2 고정)과의 교차로 벽 **높이**를 정했으나, 높이가
* 관경+여유고로 고정된 뒤로는 그 방식이 성립하지 않는다. 물매는 고정값이 아니라
* **1:1.2~2.0 범위**(`성토_비탈면.md` §1)이므로, 벽 자리를 정하면 물매가 따라 정해진다.
* 노견에서 멀어질수록 물매는 급 → 완만으로 단조 증가하므로 **범위에 처음 드는 자리**
* (= 사면이 가장 짧은 1:1.2 쪽)를 고른다. 사면길이 5m(별표2)를 넘기 전까지만 훑는다.
*
* 범위 안에 드는 자리가 없으면(급경사 지형) 5m 한계 직전 자리를 쓰고 물매를 끝값으로
* 고정한다 — `ratioClamped`로 알린다.
*/
export function solveFillWallOffset(
edge: { offset_m: number; elevation_m: number },
outward: number,
height: number,
baseAt: (offset: number) => number,
limitOffset: number,
): FillWallPlacement | null {
const span = (limitOffset - edge.offset_m) * outward;
if (!(span > 0)) return null;
const steps = 400;
const inRange: FillWallPlacement[] = [];
let last: FillWallPlacement | null = null;
for (let i = 0; i <= steps; i += 1) {
const offset = edge.offset_m + outward * ((span * i) / steps);
const joint = offset + outward * REVET_TRAP_TOP_M;
const rise = edge.elevation_m - (baseAt(offset) + height);
if (!(rise > 1e-6)) continue;
const run = Math.abs(joint - edge.offset_m);
const slopeLengthM = Math.hypot(run, rise);
// 사면길이 5m를 넘는 자리는 구조물 의무 구간이라 벽 자리 후보가 아니다(별표2).
if (slopeLengthM > FILL_SLOPE_MAX_LENGTH_M) break;
const candidate: FillWallPlacement = {
offset,
ratio: run / rise,
slopeLengthM,
withinLimit: true,
ratioClamped: false,
fillHeightM: rise,
};
last = candidate;
if (candidate.ratio >= FILL_SLOPE_RATIO_MIN && candidate.ratio <= FILL_SLOPE_RATIO_MAX) {
inRange.push(candidate);
}
}
if (!last) return null;
if (inRange.length) {
// 성토 전체고가 3m 이상이면 1:1.2로도 사면이 5m에 육박한다 — 벽을 **사면길이 5m
// 쪽(가장 바깥 후보)**으로 끌어올린다(2026-08-21 사용자 ①). 낮은 성토는 사면을
// 짧게 두는 **가장 안쪽 후보**를 쓴다.
return last.fillHeightM >= FILL_STRUCTURE_HEIGHT_M ? inRange[inRange.length - 1] : inRange[0];
}
// 범위 안에 드는 자리가 없다 = 지형이 1:1.2보다 급하거나 1:2.0보다 완만하다.
// 5m 한계 직전 자리를 쓰고 물매를 가까운 끝값으로 고정한다 — 도면에 경고로 알린다.
return {
...last,
ratio: last.ratio < FILL_SLOPE_RATIO_MIN ? FILL_SLOPE_RATIO_MIN : FILL_SLOPE_RATIO_MAX,
withinLimit: last.slopeLengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6,
ratioClamped: true,
};
}
/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도)과 그 물매·경고. */
export interface FillSlopeSegment {
ratio: number;
@@ -40,6 +40,9 @@ export interface WallLayout {
topBack: OffsetPoint;
/** 이음선 상단점(평행사변형↔사다리꼴 상단 교차점) — 성토 사면선이 여기서 끝난다. */
topJoint: OffsetPoint;
/** 하단선 양 끝 — **원지반을 수직 0.5m 내린 선**이라 사면에서는 기울어 있다. */
bottomBack: OffsetPoint;
bottomFront: OffsetPoint;
}
/**