diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 45546878a..52e447655 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -452,6 +452,9 @@ export interface CrossDesign { /** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */ widening_left_m?: number; widening_right_m?: number; + /** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6) — 사용자 값 · 노체 · 측구 · 사면 · 구조물 같이. */ + shoulder_extend_left_m?: number; + shoulder_extend_right_m?: number; cross_slope_pct: number; ditch: | { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts new file mode 100644 index 000000000..022a7f12f --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts @@ -0,0 +1,90 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Culvert_Fit.ts + * 관 길이 **m 단위 맞춤** — 배수관 기하(`_Cross_Culvert_Geom.ts`)에서 700줄 제한으로 분리. + * + * · 보통(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 벽 자리를 관 축 방향 바깥으로 + * 민다(벽 두께 0.45 · 높이 고정 · 벽 상단은 1:1.2 성토선을 탐). + * · 노견을 넓힌 쪽(139-6 설계선 쉬프트) — 사면 우선: 벽을 관 축을 따라 옮기되 벽 상단이 그 쪽 + * 성토선(넓힌 노견에서 같은 물매)에 닿도록 **벽 높이를 0.1 m 눈금으로 역산**. 눈금 탓에 남는 + * 차는 표기 올림. + * ========================================================================== */ + +import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { + FILL_SLOPE_MAX_LENGTH_M, + FILL_SLOPE_RATIO_MAX, + FILL_SLOPE_RATIO_MIN, + REVET_EMBED_DEPTH_M, + REVET_HEIGHT_STEP_M, + WALL_MIN_PURE_HEIGHT_M, +} from "./B06_Section_UI_Cross_Culvert_Const"; + +export interface OutletFitInput { + /** 유출 쪽 노견을 넓혔나(139-6) — 높이 역산 갈래. */ + shifted: boolean; + edge: { offset_m: number; elevation_m: number }; + outward: number; + axis: () => OffsetPoint; + thickness: number; + anchor: OffsetPoint; + /** 계산용 높이(순수 − 근입). */ + height: number; + /** 보통 갈래의 invert 선(높이 고정 · 성토선 평행). */ + lineInvertAt: (offset: number) => number; + slopeAt: ( + offset: number, + thickness: number, + height: number, + ) => { ratio: number; lengthM: number }; + cutLength: () => number; + /** 벽을 새 자리 · 높이로 다시 세우고 관 축 길이 `target` 으로 겨눔 — 벽이 안 서면 false. */ + rebuild: (anchor: OffsetPoint, height: number, target: number) => boolean; +} + +/** 맞춤 뒤 벽 자리 · 높이. */ +export function fitOutletPipeLength(c: OutletFitInput): { anchor: OffsetPoint; height: number } { + const target = Math.ceil(c.cutLength() - 1e-6); + let anchor = c.anchor; + let height = c.height; + const ratio0 = c.slopeAt(anchor.offset, c.thickness, height).ratio; + for (let pass = 0; pass < 8; pass += 1) { + const gap = target - c.cutLength(); + if (Math.abs(gap) < 0.005) break; + const axis = c.axis(); + const moved = anchor.offset + axis.offset * gap; + // 노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저. + if ((moved - c.edge.offset_m) * c.outward < 0) break; + if (c.shifted) { + // 사면 우선 — invert 는 관 축을 따라 · 벽 상단 = 같은 물매 성토선 → 높이 역산(0.1 m 눈금) + if (!Number.isFinite(ratio0)) break; + const invert = anchor.elevation + axis.elevation * gap; + const run = Math.abs(moved + c.outward * (c.thickness / 2) - c.edge.offset_m); + const pure = c.edge.elevation_m - run / ratio0 - invert + REVET_EMBED_DEPTH_M; + const stepped = Math.round(pure / REVET_HEIGHT_STEP_M) * REVET_HEIGHT_STEP_M; + if (stepped < WALL_MIN_PURE_HEIGHT_M - 1e-9) break; + const next = stepped - REVET_EMBED_DEPTH_M; + if (Math.abs(next - height) < 1e-9 && Math.abs(moved - anchor.offset) < 0.01) break; + anchor = { offset: moved, elevation: invert }; + height = next; + if (!c.rebuild(anchor, height, target)) break; + continue; + } + const movedSlope = c.slopeAt(moved, c.thickness, height); + // 사면 역전(벽 상단이 노견 위)으로는 옮기지 않는다 · 성토고 0(성토선 0점)까지는 허용(2026-08-23). + if (c.edge.elevation_m - (c.lineInvertAt(moved) + height) < 0) break; + // 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 — + // 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적). + if ( + movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 && + movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && + movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 + ) { + anchor = { offset: moved, elevation: c.lineInvertAt(moved) }; + if (!c.rebuild(anchor, height, target)) break; + continue; + } + // 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다). + break; + } + return { anchor, height }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 96334b0aa..6aceb2af5 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -11,7 +11,6 @@ import { revetWallSpec, pipeWallDefaultD, FILL_SLOPE_MAX_LENGTH_M, - FILL_SLOPE_RATIO_MAX, FILL_SLOPE_RATIO_MIN, MIN_PIPE_COVER_M, REVET_EMBED_DEPTH_M, @@ -58,6 +57,7 @@ import { } from "./B06_Section_UI_Cross_Culvert_Basin"; import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import { withBackCut } from "./B06_Section_UI_Cross_Structure_Cut"; +import { fitOutletPipeLength } from "./B06_Section_UI_Cross_Culvert_Fit"; import { buildCulvertTiers, drawnFillPoints, @@ -492,76 +492,61 @@ export function computeCulvertLayout( outlet: endCorners("outlet", outlet), }; - // ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 - // 벽 자리를 관 축 방향 바깥으로 민다(벽 두께 0.45 고정). - // 정수 맞춤이 옮길 후보 자리도 같은 성토선을 탄다(2026-08-23 — 0점 기준 통일). - const outletSlopeAt = outletSlopeFactory(outletInfo, outletLineInvertAt, outletWallSpec.height); - /** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */ - const outletThickness = REVET_THICKNESS_M; + // ── 관 길이 m 단위 맞춤 — `_Culvert_Fit`(보통 = 유출 벽을 관 축 바깥으로 · 노견을 넓힌 + // 쪽 = 사면 우선 + 벽 높이 역산 · 139-6). 정수 맞춤 후보 자리도 같은 성토선을 탄다(2026-08-23). + const outletThickness = REVET_THICKNESS_M; // 늘 0.45 고정(2026-08-21) — 폭으로 길이를 맞추지 않음 // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다. // d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값). const outletPinned = !!outletBody || Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null; if (outletWall && !outletPinned) { - const target = Math.ceil(cutLength(pipeCorners) - 1e-6); - const rebuild = (): void => { - walls.pop(); - outletWall = buildWall( - culvert.outlet, - outletWallAnchor, - outletInfo.outward, - null, - outletThickness, - { - height: outletWallSpec.height, - baseElevation: outletWallAnchor.elevation, - floatGapM: Math.max(0, outletWallAnchor.elevation - invertAt(outletWallAnchor.offset)), - }, - outletWallSpec.material, - outletWallSpec.form, - outletWallSpec.lean, - ); - // 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①). - const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor; - const runW = face.offset - pipeStart.offset; - const riseW = face.elevation - pipeStart.elevation; - const lenW = Math.hypot(runW, riseW) || 1; - outlet.offset = pipeStart.offset + (runW / lenW) * target; - outlet.elevation = pipeStart.elevation + (riseW / lenW) * target; - deriveAxis(); - pipeCorners = { - inlet: endCorners("inlet", pipeStart), - outlet: endCorners("outlet", outlet), - }; - }; - for (let pass = 0; pass < 8; pass += 1) { - const gap = target - cutLength(pipeCorners); - if (Math.abs(gap) < 0.005) break; - // ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위). - const moved = outletWallAnchor.offset + pipeAxis.axis.offset * gap; - const movedSlope = outletSlopeAt(moved, outletThickness); - // 사면 역전(벽 상단이 노견 위)·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 - // 도면 성립이 먼저다. 성토고 0(성토선 0점)까지는 허용(2026-08-23 사용자). - if ( - (moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 || - outletInfo.edge.elevation_m - (outletLineInvertAt(moved) + outletWallSpec.height) < 0 - ) { - break; - } - // 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 — - // 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적). - if ( - movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 && - movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && - movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 - ) { - outletWallAnchor = { offset: moved, elevation: outletLineInvertAt(moved) }; - rebuild(); - if (!outletWall) break; - continue; - } - // ② 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다). - break; - } + const extend = + section.design?.[`shoulder_extend_${outletInfo.outward > 0 ? "left" : "right"}_m`]; + fitOutletPipeLength({ + shifted: (extend ?? 0) > 1e-9, + edge: outletInfo.edge, + outward: outletInfo.outward, + axis: () => pipeAxis.axis, + thickness: outletThickness, + anchor: outletWallAnchor, + height: outletWallSpec.height, + lineInvertAt: outletLineInvertAt, + slopeAt: (offset, thickness, height) => + outletSlopeFactory(outletInfo, outletLineInvertAt, height)(offset, thickness), + cutLength: () => cutLength(pipeCorners), + rebuild: (anchor, height, target) => { + outletWallAnchor = anchor; + walls.pop(); + outletWall = buildWall( + culvert.outlet, + anchor, + outletInfo.outward, + null, + outletThickness, + { + height, + baseElevation: anchor.elevation, + floatGapM: Math.max(0, anchor.elevation - invertAt(anchor.offset)), + }, + outletWallSpec.material, + outletWallSpec.form, + outletWallSpec.lean, + ); + if (!outletWall) return false; + // 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①). + const face = outletPipeEnd(outletWall); + const runW = face.offset - pipeStart.offset; + const riseW = face.elevation - pipeStart.elevation; + const lenW = Math.hypot(runW, riseW) || 1; + outlet.offset = pipeStart.offset + (runW / lenW) * target; + outlet.elevation = pipeStart.elevation + (riseW / lenW) * target; + deriveAxis(); + pipeCorners = { + inlet: endCorners("inlet", pipeStart), + outlet: endCorners("outlet", outlet), + }; + return true; + }, + }); } if (outletWall) { // 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다).