diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index 41816084..d0fc587c 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -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)); diff --git a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts index bb6a0b39..610138cb 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts @@ -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)}`, }, }; diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index f407a841..6618ea6a 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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") {