feat(B06): 성토 물매 1:1.2 고정 — 벽이 나간 만큼 노폭 연장
물매를 1:1.2~2.0 범위에서 역산하던 방식을 걷어내고 **수평 1.2 : 수직 1 고정**으로 바꿨다(2026-08-21 사용자 확정). 벽이 1:1.2 사면이 닿는 자리보다 밖에 있으면 물매를 완만하게 바꾸는 대신 **노폭을 그만큼 늘린다** — 기슭막이를 바깥으로 1m 밀면 노폭이 약 1m 늘어난다. - fillSlopeOf가 [원 노견 → 연장된 노견 → 벽 이음선 상단점] 폴리라인을 돌려주고, 설계선 오버레이가 그 폴리라인을 그린다(노면 연장 구간이 수평으로 붙는다). - 반대로 벽이 안쪽이라 물매가 1:1.2보다 급해지는 자리는 노폭으로 못 맞춘다(도로를 좁힐 수는 없다) — 자동 자리를 1:1.2가 될 때까지 바깥으로 미는 처리를 유지한다. - fillSlope.roadWideningM 신설, 툴팁에 "노폭 연장 x.xxm" 표시. 검증(계획고 스윕 24케이스): 물매 23건이 정확히 1:1.200(나머지 1건은 성토고 0.2m로 사면이랄 게 없는 측점). 벽 이동 시험 — 밖으로 1m씩 밀 때 노폭 연장 1.52→3.27→ 4.22→5.21m, 물매는 계속 1:1.200. tsc 통과, pytest 148 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,7 +91,11 @@ export function appendCulvertOverlay(
|
||||
` · 성토사면 경사길이 ${layout.fillSlope.lengthM.toFixed(2)}m` +
|
||||
`(법정 5m ${layout.fillSlope.withinLimit ? "이내 충족" : "초과 — 구조물 보강 필요"})` +
|
||||
` · 성토 물매 1:${layout.fillSlope.ratio.toFixed(2)}` +
|
||||
`(허용 1:1.2~2.0${layout.fillSlope.ratioClamped ? " 벗어남" : " 충족"})` +
|
||||
`(1:1.2 고정${layout.fillSlope.ratioClamped ? " — 벗어남" : ""})` +
|
||||
(layout.fillSlope.roadWideningM > 0.01
|
||||
? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m` +
|
||||
`(기슭막이가 밖으로 나간 만큼 노면이 늘어난다)`
|
||||
: "") +
|
||||
(layout.fillSlope.structureRequired
|
||||
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
|
||||
: ""),
|
||||
|
||||
@@ -128,7 +128,8 @@ export function computeCulvertLayout(
|
||||
if (!basinReason && designAt) {
|
||||
const inletBaseAt = (offset: number): number =>
|
||||
Math.min(groundAt(offset), invertCap(inletInfo.edge));
|
||||
// 사면 끝이 기본 자리지만, 그 자리 물매가 1:1.2보다 급하면 규정 위반이라 바깥으로 민다.
|
||||
// 사면 끝이 기본 자리. 그 자리 물매가 1:1.2보다 급하면 규정 위반이라 바깥으로 민다
|
||||
// (완만한 쪽은 노폭 연장으로 맞춘다 — fillSlopeOf).
|
||||
inletAutoOffset = enforceMinFillRatio(
|
||||
inletInfo.edge,
|
||||
inletInfo.outward,
|
||||
@@ -206,8 +207,8 @@ export function computeCulvertLayout(
|
||||
let trimMinElevation: number | null = null;
|
||||
let trimMaxElevation: number | null = null;
|
||||
/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도). 설계선 대신 그린다. */
|
||||
let trimMinSlope: { from: OffsetPoint; to: OffsetPoint } | null = null;
|
||||
let trimMaxSlope: { from: OffsetPoint; to: OffsetPoint } | null = null;
|
||||
let trimMinSlope: { points: OffsetPoint[] } | null = null;
|
||||
let trimMaxSlope: { points: OffsetPoint[] } | null = null;
|
||||
const buildWall = (
|
||||
spec: CulvertSideSpec,
|
||||
anchor: OffsetPoint,
|
||||
@@ -619,13 +620,13 @@ export function computeCulvertLayout(
|
||||
// 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의
|
||||
// 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다).
|
||||
if (wall.outward > 0) {
|
||||
trimMax = slope.from.offset;
|
||||
trimMax = slope.points[0].offset;
|
||||
trimMaxElevation = null;
|
||||
trimMaxSlope = { from: slope.from, to: slope.to };
|
||||
trimMaxSlope = { points: slope.points };
|
||||
} else {
|
||||
trimMin = slope.from.offset;
|
||||
trimMin = slope.points[0].offset;
|
||||
trimMinElevation = null;
|
||||
trimMinSlope = { from: slope.from, to: slope.to };
|
||||
trimMinSlope = { points: slope.points };
|
||||
}
|
||||
}
|
||||
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
|
||||
@@ -646,6 +647,7 @@ export function computeCulvertLayout(
|
||||
? 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,
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
REVET_LEAN_RATIO,
|
||||
REVET_THICKNESS_M,
|
||||
@@ -118,76 +117,60 @@ export function slopeToeOffset(
|
||||
return limitOffset;
|
||||
}
|
||||
|
||||
/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도)과 그 물매·경고. */
|
||||
/** 노견 → (노폭 연장) → 벽 이음선 상단점 성토 사면 구간과 그 제원. */
|
||||
export interface FillSlopeSegment {
|
||||
ratio: number;
|
||||
lengthM: number;
|
||||
clamped: boolean;
|
||||
fillHeightM: number;
|
||||
from: OffsetPoint;
|
||||
to: OffsetPoint;
|
||||
/** 노폭 연장량(m, 편측). 벽이 1:1.2 사면이 닿는 자리보다 밖에 있으면 그만큼 늘린다. */
|
||||
wideningM: number;
|
||||
/** 그릴 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. */
|
||||
points: OffsetPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 벽이 선 자리에서 성립하는 성토 사면 구간을 만든다. 벽 자리가 다 굳은 뒤에 불러야
|
||||
* 한다 — 관 길이 맞춤(벽 이동)이 끝나기 전에 물매를 잡으면 접점이 다시 어긋난다
|
||||
* (2026-08-21 사용자 ② 어긋남의 직접 원인이 이 순서였다).
|
||||
* 벽이 선 자리에서 성토 사면 구간을 만든다. 물매는 **1:1.2 고정**(수평 1.2 : 수직 1,
|
||||
* 2026-08-21 사용자 확정)이고, 벽이 그 사면이 닿는 자리보다 밖에 있으면 부족한 만큼
|
||||
* **노폭을 늘린다** — 물매를 완만하게 바꾸지 않는다.
|
||||
*
|
||||
* 벽 자리가 다 굳은 뒤에 불러야 한다 — 관 길이 맞춤(벽 이동)이 끝나기 전에 잡으면
|
||||
* 접점이 다시 어긋난다.
|
||||
*/
|
||||
export function fillSlopeOf(
|
||||
wall: { role: "inlet" | "outlet"; topJoint: OffsetPoint },
|
||||
wall: { role: "inlet" | "outlet"; topJoint: OffsetPoint; outward: number },
|
||||
edge: { offset_m: number; elevation_m: number },
|
||||
): FillSlopeSegment {
|
||||
const from: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m };
|
||||
const to = wall.topJoint;
|
||||
const run = Math.abs(to.offset - from.offset);
|
||||
const rise = from.elevation - to.elevation;
|
||||
// 그림과 어긋나지 않도록 **실제 물매**를 그대로 보고하고, 1:1.2~2.0을 벗어났는지만
|
||||
// 따로 알린다(끝값으로 바꿔 보고하면 도면과 수치가 갈린다).
|
||||
const ratio = rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY;
|
||||
// 성토고가 0.5m도 안 되면 사면이랄 게 없다 — 물매가 발산해도 경고하지 않는다.
|
||||
const meaningful = rise >= 0.5;
|
||||
return {
|
||||
ratio,
|
||||
lengthM: Math.hypot(run, rise),
|
||||
clamped: meaningful && (ratio < FILL_SLOPE_RATIO_MIN || ratio > FILL_SLOPE_RATIO_MAX),
|
||||
fillHeightM: rise,
|
||||
from,
|
||||
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;
|
||||
// 안쪽으로 당기면 사면이 짧아져 물매가 급해진다 — **1:1.2(가장 급한 한계)**를 넘지
|
||||
// 않게 막는다(2026-08-21 사용자 지시). 바깥으로 밀 때는 완만해지므로 여기서 막지
|
||||
// 않고(관 길이 조정 여지를 남긴다) 범위 밖이면 경고로만 알린다.
|
||||
const inward = (wantedOffset - autoOffset) * outward < 0;
|
||||
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;
|
||||
const rise = edge.elevation_m - (baseAt(candidate) + height);
|
||||
if (rise < FILL_MIN_RISE_M) break;
|
||||
if (inward) {
|
||||
const joint = candidate - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
|
||||
if (Math.abs(joint - edge.offset_m) / rise < FILL_SLOPE_RATIO_MIN) break;
|
||||
}
|
||||
allowed = candidate;
|
||||
const rise = edge.elevation_m - to.elevation;
|
||||
const edgePoint: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m };
|
||||
if (!(rise > 1e-6)) {
|
||||
return {
|
||||
ratio: FILL_SLOPE_RATIO_MIN,
|
||||
lengthM: 0,
|
||||
clamped: false,
|
||||
fillHeightM: Math.max(rise, 0),
|
||||
wideningM: 0,
|
||||
points: [edgePoint, to],
|
||||
};
|
||||
}
|
||||
return allowed;
|
||||
// 1:1.2 사면이 벽 상단에 닿으려면 노견이 여기까지 나와 있어야 한다.
|
||||
const neededEdgeOffset = to.offset - wall.outward * FILL_SLOPE_RATIO_MIN * rise;
|
||||
const widening = Math.max(0, (neededEdgeOffset - edge.offset_m) * wall.outward);
|
||||
const startOffset = edge.offset_m + wall.outward * widening;
|
||||
const points: OffsetPoint[] = [edgePoint];
|
||||
if (widening > 1e-6) points.push({ offset: startOffset, elevation: edge.elevation_m });
|
||||
points.push(to);
|
||||
const run = Math.abs(to.offset - startOffset);
|
||||
return {
|
||||
ratio: run / rise,
|
||||
lengthM: Math.hypot(run, rise),
|
||||
// 벽이 안쪽으로 들어와 1:1.2보다 급해진 경우만 경고한다(연장은 정상 동작).
|
||||
clamped: run / rise < FILL_SLOPE_RATIO_MIN - 1e-6,
|
||||
fillHeightM: rise,
|
||||
wideningM: widening,
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,10 +211,42 @@ export function outletSlopeFactory(
|
||||
}
|
||||
|
||||
/**
|
||||
* 성토 물매가 **1:1.2보다 급하면** 규정 위반이다(`성토_비탈면.md` §1 — 1:1.2~2.0).
|
||||
* 사면 끝에 세운 자리가 그렇다면 물매가 1:1.2에 닿을 때까지 **바깥으로** 민다.
|
||||
* 지반이 1:1.2보다 급해 끝내 못 맞추면 시작 자리를 그대로 돌려준다 — 그때는
|
||||
* 기슭막이로 못 받는 자리라 옹벽·석축 검토 대상이고 경고로 알린다.
|
||||
* 손으로 민 벽 자리를 **성토가 성립하는 범위**로 되돌린다.
|
||||
* · 벽은 제 노견 바깥에 선다(도로 밑을 파고들면 안 된다)
|
||||
* · 성토고 하한(FILL_MIN_RISE_M) — 그 아래면 사면선이 역전된다
|
||||
* · 안쪽으로 당길 때는 **물매 1:1.2**를 넘지 않는 자리까지만(2026-08-21 사용자 지시).
|
||||
* 바깥으로 밀 때는 물매가 1:1.2로 고정된 채 노폭이 늘어나므로 막지 않는다.
|
||||
*/
|
||||
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;
|
||||
const inward = (wantedOffset - autoOffset) * outward < 0;
|
||||
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;
|
||||
const rise = edge.elevation_m - (baseAt(candidate) + height);
|
||||
if (rise < FILL_MIN_RISE_M) break;
|
||||
if (inward) {
|
||||
const joint = candidate - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
|
||||
if (Math.abs(joint - edge.offset_m) / rise < FILL_SLOPE_RATIO_MIN) break;
|
||||
}
|
||||
allowed = candidate;
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사면 끝 자리의 물매가 **1:1.2보다 급하면** 규정 위반이라 1:1.2가 될 때까지 바깥으로
|
||||
* 민다. 반대로 완만한 쪽(1:1.2보다 느슨)은 `fillSlopeOf`가 **노폭을 늘려** 맞추므로
|
||||
* 여기서 건드리지 않는다(2026-08-21 사용자 확정 — 물매는 1:1.2 고정).
|
||||
* 지반이 1:1.2보다 급해 끝내 못 맞추면 시작 자리를 돌려주고 경고로 알린다.
|
||||
*/
|
||||
export function enforceMinFillRatio(
|
||||
edge: { offset_m: number; elevation_m: number },
|
||||
@@ -241,13 +256,11 @@ export function enforceMinFillRatio(
|
||||
startOffset: number,
|
||||
limitOffset: number,
|
||||
): number {
|
||||
// 자리 기준은 **하단선 중점**이라 이음선 상단점은 중점보다 안쪽에 있다.
|
||||
const jointOf = (offset: number): number =>
|
||||
offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
|
||||
const ratioAt = (offset: number): number => {
|
||||
const rise = edge.elevation_m - (baseAt(offset) + height);
|
||||
if (!(rise > 1e-6)) return Number.POSITIVE_INFINITY;
|
||||
return Math.abs(jointOf(offset) - edge.offset_m) / rise;
|
||||
const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
|
||||
return Math.abs(joint - edge.offset_m) / rise;
|
||||
};
|
||||
if (ratioAt(startOffset) >= FILL_SLOPE_RATIO_MIN) return startOffset;
|
||||
const span = (limitOffset - startOffset) * outward;
|
||||
|
||||
@@ -18,9 +18,12 @@ export interface CulvertDesignTrim {
|
||||
/** 트림 경계에서 성토 경사선이 **닿아야 할 표고**(= 벽 이음선 상단점). 없으면 설계선 그대로. */
|
||||
minElevation?: number;
|
||||
maxElevation?: number;
|
||||
/** 노견 → 벽 이음선 상단점 **성토 사면 구간**. 백엔드 설계선(1:1.2 고정)을 대신해 그린다. */
|
||||
minSlope?: { from: OffsetPoint; to: OffsetPoint };
|
||||
maxSlope?: { from: OffsetPoint; to: OffsetPoint };
|
||||
/**
|
||||
* 노견 → (노폭 연장) → 벽 이음선 상단점 폴리라인. 물매가 1:1.2 고정이라 벽이 밖으로
|
||||
* 나간 만큼 **노폭이 늘어난다** — 그 연장 구간이 가운데 점으로 들어온다.
|
||||
*/
|
||||
minSlope?: { points: OffsetPoint[] };
|
||||
maxSlope?: { points: OffsetPoint[] };
|
||||
}
|
||||
|
||||
export interface WallLayout {
|
||||
@@ -102,6 +105,8 @@ export interface CulvertLayout {
|
||||
ratio: number;
|
||||
/** 물매가 1.2~2.0 범위를 벗어났는지 — 도면 경고용. */
|
||||
ratioClamped: boolean;
|
||||
/** 물매 1:1.2를 지키려고 늘린 **노폭 연장량**(m, 편측). 0이면 연장 없음. */
|
||||
roadWideningM: number;
|
||||
/**
|
||||
* 기슭막이만으로는 성토를 받을 수 없는 자리(성토고 3m↑인데 사면 5m·물매 1:1.2를
|
||||
* 동시에 만족하는 벽 자리가 없다) — **옹벽·석축 등 구조물 검토 대상**
|
||||
|
||||
@@ -451,14 +451,8 @@ export function appendCrossDesignOverlay(
|
||||
maxOffset: number;
|
||||
minElevation?: number;
|
||||
maxElevation?: number;
|
||||
minSlope?: {
|
||||
from: { offset: number; elevation: number };
|
||||
to: { offset: number; elevation: number };
|
||||
};
|
||||
maxSlope?: {
|
||||
from: { offset: number; elevation: number };
|
||||
to: { offset: number; elevation: number };
|
||||
};
|
||||
minSlope?: { points: Array<{ offset: number; elevation: number }> };
|
||||
maxSlope?: { points: Array<{ offset: number; elevation: number }> };
|
||||
},
|
||||
): void {
|
||||
const line = design.design_line;
|
||||
@@ -604,14 +598,20 @@ export function appendCrossDesignOverlay(
|
||||
// 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일
|
||||
// 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도).
|
||||
for (const segment of [trim?.minSlope, trim?.maxSlope]) {
|
||||
if (!segment) continue;
|
||||
const seg = document.createElementNS(SVG_NS, "line");
|
||||
seg.setAttribute("x1", String(x(segment.from.offset)));
|
||||
seg.setAttribute("y1", String(toDisplayY(segment.from.elevation)));
|
||||
seg.setAttribute("x2", String(x(segment.to.offset)));
|
||||
seg.setAttribute("y2", String(toDisplayY(segment.to.elevation)));
|
||||
seg.setAttribute("class", "b06-chart__design-cross");
|
||||
svg.append(seg);
|
||||
if (!segment || segment.points.length < 2) continue;
|
||||
// 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. 물매가 1:1.2 고정이라
|
||||
// 벽이 밖으로 나간 만큼 노면이 연장된다(2026-08-21 사용자 확정).
|
||||
for (let i = 1; i < segment.points.length; i += 1) {
|
||||
const a = segment.points[i - 1];
|
||||
const b = segment.points[i];
|
||||
const seg = document.createElementNS(SVG_NS, "line");
|
||||
seg.setAttribute("x1", String(x(a.offset)));
|
||||
seg.setAttribute("y1", String(toDisplayY(a.elevation)));
|
||||
seg.setAttribute("x2", String(x(b.offset)));
|
||||
seg.setAttribute("y2", String(toDisplayY(b.elevation)));
|
||||
seg.setAttribute("class", "b06-chart__design-cross");
|
||||
svg.append(seg);
|
||||
}
|
||||
}
|
||||
|
||||
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
|
||||
|
||||
Reference in New Issue
Block a user