diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index 2db3690e..7dd2829e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -18,6 +18,7 @@ import { REVET_FREEBOARD_M, REVET_LEAN_RATIO, } from "./B06_Section_UI_Cross_Culvert_Const"; +import { minShoulderWallOffset, slopeToeOffset } from "./B06_Section_UI_Cross_Culvert_Solve"; import type { BasinLayout, BasinShape, @@ -72,25 +73,44 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { ? Math.max(diameterM + REVET_FREEBOARD_M, edge.elevation_m - anchor.elevation) : BASIN_INNER_HEIGHT_M; // 기움은 **수직 기준 반전** — 상단이 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20). - const wallOf = (baseOffset: number, dir: number): OffsetPoint[] => { + // bottomElevation을 내리면 면 기울기를 그대로 연장한다(I형 근입용). + const defaultBottom = anchor.elevation - floorThickness; + const wallOf = ( + baseOffset: number, + dir: number, + bottomElevation = defaultBottom, + ): OffsetPoint[] => { const topShift = -dir * REVET_LEAN_RATIO * wallHeight; + const spanZ = wallHeight + floorThickness; + const xAt = (elevation: number): number => + baseOffset + topShift * ((elevation - defaultBottom) / spanZ); return [ - { offset: baseOffset, elevation: anchor.elevation - floorThickness }, + { offset: xAt(bottomElevation), elevation: bottomElevation }, { offset: baseOffset + topShift, elevation: anchor.elevation + wallHeight }, { offset: baseOffset + topShift + dir * memberT, elevation: anchor.elevation + wallHeight, }, { - offset: baseOffset + dir * memberT, - elevation: anchor.elevation - floorThickness, + offset: xAt(bottomElevation) + dir * memberT, + elevation: bottomElevation, }, ]; }; // I형 벽 자리 = **상단 도로측 꼭짓점이 노견 끝 지점에 닿는다**(2026-08-22 사용자 // 확정). 벽 상단이 도로측으로 1:0.3 물러나므로 하단(base)은 그만큼 계류측 바깥. const wallBase = anchor.offset + outward * (REVET_LEAN_RATIO * wallHeight); - const parts: BasinLayout["parts"] = [{ kind: "wall", points: wallOf(wallBase, outward) }]; + // I형 벽 하단은 **원지반까지 묻는다**(2026-08-22 사용자 — 지반 아래 부재두께만큼 + // 근입 후 배관을 둔다). ㄴ·ㄷ형은 바닥판이 원지반을 대체하므로 현행 유지. + const iWallBottom = + shape === "I" + ? Math.min(anchor.elevation, groundAt(wallBase), groundAt(wallBase + outward * memberT)) - + floorThickness + : defaultBottom; + const parts: BasinLayout["parts"] = [ + { kind: "wall", points: wallOf(wallBase, outward, iWallBottom) }, + ]; + const iWallPoints = parts[0].points; // ㄴ형 = I형 + 바닥판. 바닥은 벽 바깥(계류측) 변에 맞대고 그 너머로 뻗는다 // (2026-08-20 확정). 벽이 기울어 있어 안쪽 변도 벽 바깥면 선을 그대로 따른다. const wallOuterAt = (elevation: number): number => { @@ -172,15 +192,12 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { fillLine: basinFill, }, // 관 끝선 = I형 벽의 안쪽(물받는 쪽 = 내공측) 변 복사(2026-08-22 사용자 확정). + // 벽 폴리곤의 하단-전면(3)~상단-전면(2) 꼭짓점을 그대로 쓴다 — I형 근입 연장도 반영. endFace: { - base: { - offset: wallBase + outward * memberT, - elevation: anchor.elevation - floorThickness, - }, - // 변 전체(바닥−두께 ~ 상단) 기울기 — x 이동량은 벽 기움(0.3×벽높이). + base: iWallPoints[3], direction: { - offset: -outward * REVET_LEAN_RATIO * wallHeight, - elevation: wallHeight + floorThickness, + offset: iWallPoints[2].offset - iWallPoints[3].offset, + elevation: iWallPoints[2].elevation - iWallPoints[3].elevation, }, }, pipeEnd: @@ -191,3 +208,45 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { trimElevation: anchor.elevation + wallHeight, }; } + +/** + * 조정창 드롭다운 선택지 가용성(2026-08-22 사용자 — 상황에 안 맞는 선택지 숨김): + * · 기슭막이+배관: 자동 자리의 원지반이 토피 상한(노면−관경−토피)보다 높으면 배관이 + * 원지반에 묻힐 수밖에 없다 → 숨김. + * · 집수정 ㄴ·ㄷ형: ㄴ형으로 가정해 세워 봤을 때 구조물이 원지반으로 밀고 들어가지 + * 않으면(절토선이 안 생기면) → 숨김. + */ +export function inletChoiceAvailability(input: { + designAt: ((offset: number) => number) | null; + groundAt: (offset: number) => number; + edge: { offset_m: number; elevation_m: number }; + outward: number; + limitOffset: number; + wallHeight: number; + invertCapM: number; + diameterM: number; + cutSlopeRatio: number; +}): { revetAllowed: boolean; basinLUAllowed: boolean; revetAutoOffset: number } { + const { designAt, groundAt, edge, outward, limitOffset, wallHeight, invertCapM } = input; + let revetAllowed = true; + // 기슭막이 자동 자리(노견 최소 지점 ?? 사면 끝) — 본체가 벽 배치에도 재사용한다. + let revetAutoOffset = edge.offset_m; + if (designAt) { + const baseAt = (offset: number): number => Math.min(groundAt(offset), invertCapM); + const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limitOffset); + revetAutoOffset = + minShoulderWallOffset(edge, outward, wallHeight, baseAt, toe, limitOffset) ?? toe; + revetAllowed = groundAt(revetAutoOffset) <= invertCapM + 0.02; + } + const probe = buildBasin({ + anchor: { offset: edge.offset_m, elevation: edge.elevation_m - BASIN_INNER_HEIGHT_M }, + outward, + shape: "L", + reason: "short", + diameterM: input.diameterM, + edge, + groundAt, + cutSlopeRatio: input.cutSlopeRatio, + }); + return { revetAllowed, basinLUAllowed: probe.basin.cutLine != null, revetAutoOffset }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 4994bd05..e87fbe22 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -1,18 +1,11 @@ /* ============================================================================= * B06_Section_UI_Cross_Culvert_Geom.ts * 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리. - * 백엔드가 만든 `section.culvert` 제원을 좌표로 옮긴다 — 치수를 새로 정하지 않는다. - * - * 배치 규칙 — 실무 횡단도 4장 기준(2026-08-20 사용자 제공, 울진 계열 주기): - * · 기슭막이 2기 — 유입/유출측 성토사면 안. 사면 쪽으로 기운 평행사변형(전면 1:0.3, - * 두께 0.45m). 자리는 사면선이 이음선 상단점을 지나도록 풀어서 정한다. - * · 배관 — 두 벽 사이(도로 하부)만 직선. 끝단면은 벽 전면과 평행하게 마감(수평 가능). - * · 보호공(돌붙임) — 관 하단 꼭짓점에서 성토사면 경사를 따라 원지반까지, 최소 길이 - * (낙차고×2)를 채울 때까지 지반을 따라 더 간다. 유입측은 수평 보호공 없음. - * · 성토 경사 — 벽 전면과 교차된 이후는 끊는다(designTrim — 벽·보호공이 대신한다). - * - * 유입 = 상단측(uphill_side), 유출 = 하단측. 유입구가 "집수정"이면 그쪽 기슭막이는 - * 그리지 않고 집수정 단면(I/ㄴ/ㄷ형)으로 바꾼다. + * 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 배치 규칙은 실무 + * 횡단도 기준(2026-08-20 사용자 제공, 울진 계열): 기슭막이 평행사변형(전면 1:0.3), + * 배관은 두 벽 사이 직선·끝단면은 구조물 변과 평행, 보호공은 관 하단 꼭짓점부터 + * 원지반까지(최소 낙차고×2), 성토 경사선은 벽 접점에서 끊는다(designTrim). + * 유입 = 상단측(uphill_side). 집수정 구성은 `_Cross_Culvert_Basin.ts` 참조. * ========================================================================== */ import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; @@ -45,7 +38,11 @@ import type { export * from "./B06_Section_UI_Cross_Culvert_Const"; export * from "./B06_Section_UI_Cross_Culvert_Types"; -import { BASIN_INNER_WIDTH_M, buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin"; +import { + BASIN_INNER_WIDTH_M, + buildBasin, + inletChoiceAvailability, +} from "./B06_Section_UI_Cross_Culvert_Basin"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { clampToFace, @@ -158,6 +155,18 @@ export function computeCulvertLayout( // 하므로 자리를 풀어서 정한다(집수정은 측구부 자리 그대로). const wallHeightFor = (spec: CulvertSideSpec): number => Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); + // 조정창 드롭다운 선택지 가용성 — 판정은 분리 파일(Basin)이 맡는다(700줄 제한). + const inletOptions = inletChoiceAvailability({ + designAt, + groundAt, + edge: inletInfo.edge, + outward: inletInfo.outward, + limitOffset: inletInfo.limit, + wallHeight: wallHeightFor(culvert.inlet), + invertCapM: invertCap(inletInfo.edge), + diameterM: diameter, + cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, + }); /** 요청값이 한계에 걸려 잘린 뒤의 **실제** 이동량. 조정창이 이 값을 되받는다. */ const appliedShift = { inlet: 0, outlet: 0 }; /** 손으로 민 벽의 수직 제원(높이 증가·바닥 띄움) — buildWall에 물려 준다. */ @@ -172,16 +181,8 @@ export function computeCulvertLayout( if (!basinReason && designAt) { const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); - const inletToe = slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit); - const inletFeasible = minShoulderWallOffset( - inletInfo.edge, - inletInfo.outward, - wallHeightFor(culvert.inlet), - inletBaseAt, - inletToe, - inletInfo.limit, - ); - inletAutoOffset = inletFeasible ?? inletToe; + // 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용. + inletAutoOffset = inletOptions.revetAutoOffset; // 안쪽 당김 한계 = **노견**(도로 밑 침범 금지). 1:1.2 자리 안쪽으로 당기면 // ① 높이 증가 ② 한계 도달 시 바닥 띄움으로 노견·물매를 지킨다 // (2026-08-22 사용자 재확정 — 13+15.7 확인 요청). @@ -654,6 +655,7 @@ export function computeCulvertLayout( const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null; return { + inletOptions, culvert, pipe: { inlet: pipeStart, outlet, lengthM, slopePct }, pipeAxis: { axis, normal }, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 6b75e6b6..2b9724b9 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -138,4 +138,10 @@ export interface CulvertLayout { * 눌러도 안 움직이는데 숫자만 계속 커진다(2026-08-21 사용자 지적). */ revetShift: { inlet: number; outlet: number }; + /** + * 조정창 드롭다운 선택지 가용성(2026-08-22 사용자 — 상황에 안 맞는 선택지 숨김): + * revetAllowed = 기슭막이+배관(배관이 원지반에 안 묻힐 때만), + * basinLUAllowed = 집수정 ㄴ·ㄷ형(구조물이 원지반으로 밀고 들어갈 때만). + */ + inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean }; } diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 2e6d925c..7edb4377 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -31,6 +31,8 @@ export interface StructurePanelDeps { setStructure: (value: InletStructureChoice) => void; /** ◀/▶ 이동 가능 여부 — 집수정(자리 고정)은 숨긴다. */ canNudge: (key: RevetKey) => boolean; + /** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */ + optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean }; } export interface StructurePanelHandle { @@ -145,7 +147,20 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan : L("B06_Cross_Struct_InletBasin"); // 형식 선택은 유입측에서만, ◀/▶/↺는 자리를 옮길 수 있는 구조물(기슭막이)만. structureRow.classList.toggle("is-hidden", key !== "inlet"); - if (key === "inlet") select.value = deps.structureFor(); + if (key === "inlet") { + const current = deps.structureFor(); + // 상황에 안 맞는 선택지는 숨긴다(2026-08-22 사용자). 단 지금 선택된 값은 + // 남긴다 — 숨기면 셀렉트가 빈 값이 된다. + const allow = deps.optionsFor(); + for (const option of select.options) { + const value = option.value as InletStructureChoice; + const hidden = + (value === "revet" && !allow.revetAllowed) || + ((value === "L" || value === "U") && !allow.basinLUAllowed); + option.hidden = hidden && value !== current; + } + select.value = current; + } buttons.classList.toggle("is-hidden", !deps.canNudge(key)); const shift = deps.shiftFor(key); // 자동 자리 기준 이동량 — 0이면 "자동"으로 적어 손을 안 댔음을 바로 알린다. diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index fb32ad8a..9082d372 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -228,6 +228,8 @@ export function createCrossSectionCard( let culvertPipeLengthM: number | null = null; /** 유입측이 집수정인가 — 조정창이 ◀/▶ 표시 여부·제목을 가른다. */ let culvertInletIsBasin = false; + /** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */ + let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true }; /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 @@ -500,6 +502,7 @@ export function createCrossSectionCard( // 배수관 측점 세트(배관·기슭막이·보호공) — 기존 도형 위에 추가만 한다(2026-08-19). culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null; culvertInletIsBasin = !!culvertLayout?.basin; + if (culvertLayout) culvertInletOptions = culvertLayout.inletOptions; // 요청한 이동량이 한계에 걸려 잘렸으면 그 값으로 되돌려 담는다 — 안 그러면 눌러도 // 안 움직이는데 창의 숫자만 계속 커진다(2026-08-21 사용자 지적). // 잘린 순간에는 **왜 안 움직였는지 토스트로 알린다**(2026-08-22 사용자 요청 — @@ -621,6 +624,7 @@ export function createCrossSectionCard( setStructure: (value) => inletStructure?.set(section.chainage_m, value), // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다. canNudge: (key) => key === "outlet" || !culvertInletIsBasin, + optionsFor: () => culvertInletOptions, }); showRevetControl = (visible) => panel.show(visible ? activeRevet : null); showRevetControl(activeRevet !== null);