fix(B06): BOX암거 성토선 추가 — 박스 최상단을 지나고 노견은 수평 연장

박스 위 성토선이 빠져 있었다. 물매 1:1.2를 고정하고 성토선이 **박스 최상단
모서리**를 지나게 하면, 박스를 길게 잡을수록 노견이 저절로 수평 연장되고 각도는
그대로다(기슭막이 "벽이 밖으로 나간 만큼 노폭 연장"과 같은 규칙, 2026-08-25 사용자).

- 구체 길이 = 노폭 + 2 × (노면 − 박스 상단) × 1.2. 노면이 크라운이라 좌·우 필요량이
  다르므로 큰 쪽에 맞춰 대칭으로 잡고, 남는 쪽은 노견을 수평 연장한다
- 성토선 = 노견 → (수평 연장) → 박스 최상단 모서리 → 1:1.2로 원지반까지
- 노견 밖 설계선은 이 성토선이 대신하므로 다시 트림한다

검증(10+0.9): 구체 ±2.744m, 저판 8.32m, 좌 성토선 물매 1.200·1.200,
우 성토선 수평 0.144m 뒤 1.200·1.200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 12:52:40 +09:00
co-authored by Claude Opus 5
parent d4c8de738a
commit 55e97b8925
3 changed files with 76 additions and 10 deletions
+16 -1
View File
@@ -5,7 +5,7 @@
* 기하 계산은 `B06_Section_UI_Cross_Box_Geom.ts`가 맡는다(700줄 제한 분리).
* 여기서는 계산 결과(`BoxLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지.
*
* 그리는 순서 = 시공 순서: ① 저판 → ② 상판 → ③ 내공 윤곽·라벨.
* 그리는 순서 = 시공 순서: ① 저판 → ② 상판 → ③ 내공 윤곽 → ④ 성토선 → 라벨.
* 콘크리트 색은 세월교 바닥판 클래스를 그대로 쓴다.
* ========================================================================== */
@@ -82,6 +82,21 @@ export function appendBoxOverlay(
);
layer.append(barrel);
// ④ 성토선 — 노견에서 (연장 후) 박스 최상단 모서리를 지나 원지반까지, 물매 1:1.2.
for (const line of layout.fillLines) {
if (line.length < 2) continue;
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute("points", line.map((point) => toXY(point).join(",")).join(" "));
polyline.setAttribute("class", "b06-chart__design-cross");
const title = document.createElementNS(SVG_NS, "title");
const height = line[0].elevation - line[line.length - 1].elevation;
title.textContent =
`BOX암거 성토선 — 물매 1:1.2 · 성토고 ${height.toFixed(2)}m` +
" (박스 최상단 모서리를 지난다)";
polyline.append(title);
layer.append(polyline);
}
const label = document.createElementNS(SVG_NS, "text");
const [labelX, labelY] = toXY(layout.label.at);
label.setAttribute("x", String(labelX));
+59 -8
View File
@@ -8,13 +8,16 @@
* · 상판 윗면 = **노면 − 복토 0.5m**(별표2 교차 참조), 구체는 수평 설치(교본 3장).
* · 저판은 **날개벽 투영**(길이×cos각)만큼 계류 상·하류로 길어진다 — 세월교와 같은 산식.
* · 내공(유로)은 비워 둔다 — 관 위 성토 채움처럼 면을 칠하지 않는다.
* · 구체가 노면 아래 묻히므로 **계획선은 자르지 않는다** — 노견 밖 성토 비탈이 실제로
* 존재한다(세월교는 측벽이 비탈을 대신해 잘랐다).
* · **성토선은 박스 최상단 모서리를 지난다**(2026-08-25 사용자). 물매 1:1.2 고정이라
* 박스가 노견 밖으로 나간 만큼 노견을 수평 연장하고 거기서 사면을 시작한다 —
* 기슭막이 "벽이 밖으로 나간 만큼 노폭 연장" 규칙과 같다. 그래서 박스 길이를
* 늘리면 노견부가 저절로 길어지고 사면 각도는 그대로다.
* 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다.
* ========================================================================== */
import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import type { CulvertDesignTrim, OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
export interface BoxLayout {
@@ -32,7 +35,14 @@ export interface BoxLayout {
slabLengthM: number;
/** 구체 바닥과 계류 하상(원지반 최저)의 고저차(m). +면 구체가 하상 위로 떠 있다. */
bedGapM: number;
/**
* 양측 성토선 — 노견 → (박스가 나간 만큼 수평 연장) → 박스 최상단 모서리 →
* 1:1.2로 원지반까지. 물매는 고정이고 길이만 지형이 정한다.
*/
fillLines: OffsetPoint[][];
label: { at: OffsetPoint; text: string };
/** 노견 밖 설계선은 이 성토선이 대신한다 — 노견에서 끊는다. */
designTrim: CulvertDesignTrim;
}
/** 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선(세월교와 같은 규칙). */
@@ -87,11 +97,21 @@ export function computeBoxLayout(
const barrelBottom = barrelTop - box.inner_height_m;
const bottomElevation = barrelBottom - box.slab_thickness_m;
// 구체 길이 — 성토선이 박스 최상단을 지나려면 (노면 − 박스 상단) × 물매만큼은 노견
// 밖으로 나가야 한다. 노면이 크라운이라 좌·우 필요량이 다르므로 **큰 쪽에 맞춰**
// 대칭으로 잡고, 남는 쪽은 노견을 수평 연장해 물매 1:1.2를 지킨다(2026-08-25 사용자).
const reach =
Math.max(roadTopAt(leftEdge), roadTopAt(rightEdge)) - topElevation > 0
? (Math.max(roadTopAt(leftEdge), roadTopAt(rightEdge)) - topElevation) * FILL_SLOPE_RATIO_MIN
: box.cover_m * FILL_SLOPE_RATIO_MIN;
const bodyLeft = leftEdge + reach;
const bodyRight = rightEdge - reach;
// 저판 연장 — 유입·유출 날개벽 각도가 만든 투영량(백엔드 계산값).
const leftExtend = Math.max((inletOnLeft ? box.wing_in : box.wing_out).slab_extend_m, 0);
const rightExtend = Math.max((inletOnLeft ? box.wing_out : box.wing_in).slab_extend_m, 0);
const slabLeft = leftEdge + leftExtend;
const slabRight = rightEdge - rightExtend;
const slabLeft = bodyLeft + leftExtend;
const slabRight = bodyRight - rightExtend;
// 계류 하상과의 고저차 — 터파기·성토 판정은 후속이라 값만 넘긴다.
let bed = Math.min(groundAt(leftEdge), groundAt(rightEdge));
@@ -99,17 +119,48 @@ export function computeBoxLayout(
bed = Math.min(bed, groundAt(offset));
}
// 성토선 — 노견 표고에서 수평으로 나간 뒤 박스 최상단 모서리를 지나 지반까지.
const fillLine = (edgeOffset: number, bodyOffset: number, outward: number): OffsetPoint[] => {
const roadZ = roadTopAt(edgeOffset);
// 사면이 박스 모서리를 지나려면 시작점이 모서리에서 (높이차 × 물매)만큼 안쪽이다.
const start = bodyOffset - outward * (roadZ - topElevation) * FILL_SLOPE_RATIO_MIN;
const corner: OffsetPoint = { offset: bodyOffset, elevation: topElevation };
const points: OffsetPoint[] = [{ offset: edgeOffset, elevation: roadZ }];
// 박스가 더 길면 노견을 수평으로 늘린다(각도 불변 — 2026-08-25 사용자).
if ((start - edgeOffset) * outward > 0.01) points.push({ offset: start, elevation: roadZ });
points.push(corner);
// 모서리 밖은 같은 물매로 원지반과 만날 때까지.
let toe = corner;
for (let drop = 0.05; drop <= 30; drop += 0.05) {
const probe: OffsetPoint = {
offset: corner.offset + outward * drop * FILL_SLOPE_RATIO_MIN,
elevation: corner.elevation - drop,
};
toe = probe;
if (probe.elevation <= groundAt(probe.offset)) break;
}
points.push(toe);
return points;
};
return {
box,
topSlab: rect(leftEdge, rightEdge, topElevation, barrelTop),
topSlab: rect(bodyLeft, bodyRight, topElevation, barrelTop),
bottomSlab: rect(slabLeft, slabRight, barrelBottom, bottomElevation),
barrel: rect(leftEdge, rightEdge, barrelTop, barrelBottom),
barrel: rect(bodyLeft, bodyRight, barrelTop, barrelBottom),
fillLines: [fillLine(leftEdge, bodyLeft, 1), fillLine(rightEdge, bodyRight, -1)],
designTrim: {
minOffset: rightEdge,
maxOffset: leftEdge,
minElevation: roadTopAt(rightEdge),
maxElevation: roadTopAt(leftEdge),
},
topElevation,
bottomElevation,
slabLengthM: Math.abs(slabLeft - slabRight),
bedGapM: Number((bottomElevation - bed).toFixed(3)),
label: {
at: { offset: (leftEdge + rightEdge) / 2, elevation: (barrelTop + barrelBottom) / 2 },
at: { offset: (bodyLeft + bodyRight) / 2, elevation: (barrelTop + barrelBottom) / 2 },
text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`,
},
};
+1 -1
View File
@@ -454,7 +454,7 @@ export function createCrossSectionCard(
x,
toDisplayY,
drawSamples,
culvertLayout?.designTrim ?? fordLayout?.designTrim ?? undefined,
culvertLayout?.designTrim ?? fordLayout?.designTrim ?? boxLayout?.designTrim,
);
// 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님).
if (rockBoundary && section.design.geometry_preset === "rock") {