diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 975d8087..fad32e45 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -200,7 +200,9 @@ export function appendCulvertOverlay( const reasonText = basin.reason === "cut" ? "절토측 유입 — 집수정 기본(성토사면 없음)" - : "유입측 성토사면 3m 이하 — 기슭막이 대신 집수정 기본(선택 기능 추가 예정)"; + : basin.reason === "manual" + ? "사용자 선택 집수정(규칙상 기슭막이 자리)" + : "유입측 성토사면 3m 이하 — 기슭막이 대신 집수정 기본"; for (const part of basin.parts) { layer.append( polygon( @@ -310,6 +312,22 @@ export function appendCulvertOverlay( }); layer.append(hit); } + // 집수정도 선택 대상(2026-08-22 사용자 — 형식 드롭다운). 자리 이동은 없고 + // 조정창에서 구조물 형식만 고른다. + if (layout.basin) { + for (const part of layout.basin.parts) { + const hit = polygon( + part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), + "b06-chart__culvert-revet-hit", + "유입 집수정 선택 — 조정창에서 구조물 형식 선택", + ); + hit.addEventListener("click", (event) => { + event.stopPropagation(); + onSelectRevet("inlet"); + }); + layer.append(hit); + } + } } // 강조는 클래스만 갈아 끼운다 — 카드를 다시 그리면 휠 줌·팬이 초기화된다. diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts new file mode 100644 index 00000000..2da976d3 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -0,0 +1,164 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Culvert_Basin.ts + * 유입 **집수정** 단면 구성 — 기하 본체(`_Cross_Culvert_Geom.ts`)에서 분리(700줄 제한). + * + * 집수정은 기슭막이와 달리 **물을 모으려고 만든 구조물**이다(2026-08-22 사용자 확정): + * · I형(공유벽뿐): 배관은 원지반 자리 유지. 기본 높이(관경+여유)로 노견 끝 접점이 + * 안 되면 벽을 노견 표고까지 키운다. + * · ㄴ(L)형(= I형 + 바닥판, 기본값): 내부 높이 1.2m(임시값) 등 사이즈를 유지한 채 + * 통째로 노견 끝점에 일치한다 — 바닥(=관 유입 invert)이 따라 올라 관 물매가 바뀐다. + * · ㄷ(U)형: ㄴ형 + 반대측(도로측) 막음벽(I형 수직반전). + * 부재(벽·바닥판) 두께 0.2m(2026-08-22 사용자 지정). 벽 자리 = 상단 도로측 꼭짓점이 + * **노견 끝 지점**에 닿는 자리(우측 집수정이면 상단 좌측점). + * ========================================================================== */ + +import { + BASIN_INNER_HEIGHT_M, + BASIN_MEMBER_THICKNESS_M, + REVET_FREEBOARD_M, + REVET_LEAN_RATIO, +} from "./B06_Section_UI_Cross_Culvert_Const"; +import type { + BasinLayout, + BasinShape, + EndFace, + OffsetPoint, +} from "./B06_Section_UI_Cross_Culvert_Types"; + +/** 집수정 구성 입력 — 기하 본체가 자기 상태를 명시적으로 물려 준다. */ +export interface BasinBuildInput { + /** 관 유입 기준점(노견 끝 offset, invert 표고 — ㄴ·ㄷ형은 상승분 반영 후). */ + anchor: OffsetPoint; + /** 계류측 방향 부호(+ = 좌). */ + outward: number; + shape: BasinShape; + reason: BasinLayout["reason"]; + /** 관 외경 지름(m). */ + diameterM: number; + /** 이 측의 노견 끝 점 — 벽 상단 접점·I형 높이 성장 기준. */ + edge: { offset_m: number; elevation_m: number }; + groundAt: (offset: number) => number; + /** 표준단면 절토 경사(1:n) — 절토 계획선용. */ + cutSlopeRatio: number; +} + +/** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */ +export interface BasinBuildResult { + basin: BasinLayout; + /** 관 끝단 마감면 = I형 벽의 안쪽(물받는 쪽) 변 복사(2026-08-22 사용자 확정). */ + endFace: EndFace; + /** 관 시작점(하단 기준) — ㄴ·ㄷ형은 측벽 안쪽 변×바닥 윗면 교차점, I형은 내공 1.0m. */ + pipeEnd: OffsetPoint; + /** 성토 사면선 트림 경계(벽 상단 도로측 꼭짓점)와 그 표고. */ + trimOffset: number; + trimElevation: number; +} + +/** 실무 내공 폭(m) — 울진 돌집수정. */ +const INNER_WIDTH_M = 1.0; + +/** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */ +export function buildBasin(input: BasinBuildInput): BasinBuildResult { + const { anchor, outward, shape, reason, diameterM, edge, groundAt, cutSlopeRatio } = input; + const memberT = BASIN_MEMBER_THICKNESS_M; + const floorThickness = memberT; + // 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값). + const wallHeight = + shape === "I" + ? 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[] => { + const topShift = -dir * REVET_LEAN_RATIO * wallHeight; + return [ + { offset: baseOffset, elevation: anchor.elevation - floorThickness }, + { offset: baseOffset + topShift, elevation: anchor.elevation + wallHeight }, + { + offset: baseOffset + topShift + dir * memberT, + elevation: anchor.elevation + wallHeight, + }, + { + offset: baseOffset + dir * memberT, + elevation: anchor.elevation - floorThickness, + }, + ]; + }; + // 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-20 확정). 벽이 기울어 있어 안쪽 변도 벽 바깥면 선을 그대로 따른다. + const wallOuterAt = (elevation: number): number => { + const bottom = anchor.elevation - floorThickness; + const t = (elevation - bottom) / (wallHeight + floorThickness); + return wallBase + outward * memberT - outward * REVET_LEAN_RATIO * wallHeight * t; + }; + const floorTopInner = wallOuterAt(anchor.elevation); + const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness); + const floorOuter = floorTopInner + outward * (INNER_WIDTH_M + memberT); + const floorTaper = REVET_LEAN_RATIO * floorThickness; + if (shape !== "I") { + parts.push({ + kind: "floor", + points: [ + { offset: floorTopInner, elevation: anchor.elevation }, + { offset: floorOuter, elevation: anchor.elevation }, + { + offset: floorOuter - outward * floorTaper, + elevation: anchor.elevation - floorThickness, + }, + { offset: floorBottomInner, elevation: anchor.elevation - floorThickness }, + ], + }); + } + // ㄷ형 = ㄴ형 + 반대측(도로측) 막음벽(I형 수직반전 — 2026-08-22 사용자 확정). + if (shape === "U") { + parts.push({ kind: "wall", points: wallOf(anchor.offset, -outward) }); + } + // ㄴ·ㄷ형이 원지반 **안쪽**에 박히면 그만큼 절토가 필요하다(2026-08-20 사용자 ①). + let basinCut: BasinLayout["cutLine"] = null; + if (shape !== "I") { + const outerTop = { offset: floorOuter, elevation: anchor.elevation }; + if (groundAt(floorOuter) > anchor.elevation + 0.05) { + for (let h = 0.05; h <= 20; h += 0.05) { + const probe = floorOuter + outward * cutSlopeRatio * h; + if (groundAt(probe) <= anchor.elevation + h) { + basinCut = { from: outerTop, to: { offset: probe, elevation: anchor.elevation + h } }; + break; + } + } + } + } + return { + basin: { + shape, + parts, + // 라벨 자리 = I형 벽 하단(2026-08-20 사용자) — 그리기 쪽에서 아래로 내려 붙인다. + label: { + offset: wallBase + outward * (memberT / 2), + elevation: anchor.elevation - floorThickness, + }, + reason, + cutLine: basinCut, + }, + // 관 끝선 = I형 벽의 안쪽(물받는 쪽 = 내공측) 변 복사(2026-08-22 사용자 확정). + endFace: { + base: { + offset: wallBase + outward * memberT, + elevation: anchor.elevation - floorThickness, + }, + // 변 전체(바닥−두께 ~ 상단) 기울기 — x 이동량은 벽 기움(0.3×벽높이). + direction: { + offset: -outward * REVET_LEAN_RATIO * wallHeight, + elevation: wallHeight + floorThickness, + }, + }, + pipeEnd: + shape === "I" + ? { offset: anchor.offset + outward * INNER_WIDTH_M, elevation: anchor.elevation } + : { offset: floorTopInner, elevation: anchor.elevation }, + trimOffset: wallBase - outward * REVET_LEAN_RATIO * wallHeight, + trimElevation: anchor.elevation + wallHeight, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index cad9aa4b..1fbd9aea 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -135,3 +135,6 @@ export const BASIN_INNER_HEIGHT_M = 1.2; * 성토사면이 없으므로 자동 포함. 사용자 선택(기슭막이 유지)은 향후 추가 예정. */ export const BASIN_MAX_FILL_SLOPE_M = 3.0; + +/** 집수정 부재(벽·바닥판) 두께(m) — 2026-08-22 사용자 지정 0.2. */ +export const BASIN_MEMBER_THICKNESS_M = 0.2; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 03160ac8..7699e6c9 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -17,7 +17,6 @@ import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; import { - BASIN_BOTTOM_CLEARANCE_M, BASIN_INNER_HEIGHT_M, BASIN_MAX_FILL_SLOPE_M, FILL_MIN_RISE_M, @@ -27,7 +26,6 @@ import { MIN_PIPE_COVER_M, PITCHING_THICKNESS_M, REVET_EMBED_DEPTH_M, - REVET_FREEBOARD_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, revetHeightLimit, @@ -47,8 +45,10 @@ import type { export * from "./B06_Section_UI_Cross_Culvert_Const"; export * from "./B06_Section_UI_Cross_Culvert_Types"; +import { buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { + clampToFace, clampWallOffset, cutLength, minShoulderWallOffset, @@ -58,16 +58,22 @@ import { designInterpolator, intersect, slopeToeOffset, + slopeLengthAlong, solveWallVertical, STRAY_LIMIT_M, } from "./B06_Section_UI_Cross_Culvert_Solve"; +/** 유입측 구조물 사용자 선택(2026-08-22) — auto = 규칙(사면≤3m → 집수정 ㄴ형). */ +export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U"; + /** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */ export function computeCulvertLayout( section: CrossSection, groundSamples: SectionSample[], /** 사용자가 손으로 민 기슭막이 X 이동량(m, + = 계류측 바깥). 없으면 자동 자리. */ revetShift?: { inlet?: number; outlet?: number }, + /** 유입측 구조물 형식 선택(드롭다운) — 없으면 auto(규칙). */ + inletStructure?: InletStructureChoice, ): CulvertLayout | null { const culvert = section.culvert; if (!culvert) return null; @@ -108,26 +114,32 @@ export function computeCulvertLayout( mode === "both_cut" || (mode === "left_cut" && inletSideName === "left") || (mode === "right_cut" && inletSideName === "right"); - let inletFillSlopeLen = 0; - if (!inletIsCut && designAt) { - const toe = slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit); - const from = inletInfo.edge.offset_m; - const steps = 60; - let prevElev = designAt(from); - for (let i = 1; i <= steps; i += 1) { - const o = from + ((toe - from) * i) / steps; - const e = designAt(o); - inletFillSlopeLen += Math.hypot((toe - from) / steps, e - prevElev); - prevElev = e; - } - } - const basinReason: BasinLayout["reason"] | null = inletIsCut + const inletFillSlopeLen = + !inletIsCut && designAt + ? slopeLengthAlong( + designAt, + inletInfo.edge.offset_m, + slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit), + ) + : 0; + const ruleReason: BasinLayout["reason"] | null = inletIsCut ? "cut" - : inletFillSlopeLen <= BASIN_MAX_FILL_SLOPE_M + 1e-6 - ? "short" - : null; - // 집수정 형식 기본값 = ㄴ(L)형(2026-08-20 확정). 사용자 선택은 향후 추가 예정. - const basinShape: BasinShape = "L"; + : culvert.inlet.structure === "집수정" + ? "cut" + : inletFillSlopeLen <= BASIN_MAX_FILL_SLOPE_M + 1e-6 + ? "short" + : null; + // 사용자 선택(드롭다운 — 2026-08-22)이 규칙보다 우선한다: + // revet = 기슭막이+배관 강제(양측성토 로직), I/L/U = 해당 형식 집수정 강제. + const choice: InletStructureChoice = inletStructure ?? "auto"; + const basinReason: BasinLayout["reason"] | null = + choice === "revet" + ? null + : choice === "I" || choice === "L" || choice === "U" + ? (ruleReason ?? "manual") + : ruleReason; + // 집수정 형식 기본값 = ㄴ(L)형(2026-08-20 확정) — 선택 시 그 형식. + const basinShape: BasinShape = choice === "I" || choice === "L" || choice === "U" ? choice : "L"; // ㄴ·ㄷ형은 **측면·바닥 사이즈를 유지한 채 통째로 노견 끝점에 일치**시킨다(2026-08-22 // 사용자 확정 — 노견과 빈 공간을 채운다). 바닥(=관 유입 invert)이 따라 올라가므로 // 관 물매도 바뀐다. 유출단은 원지반 위 원칙 그대로. I형은 원지반 배관을 유지하고 @@ -196,18 +208,9 @@ export function computeCulvertLayout( ? slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit) : outletInfo.edge.offset_m; // 성토사면 경사길이 실측(노견 → 사면 끝) — 법정 5m 이내 충족 확인용(사용자 ①). - let fillSlopeLength = 0; - if (designAt) { - const from = outletInfo.edge.offset_m; - const steps = 60; - let prevElev = designAt(from); - for (let i = 1; i <= steps; i += 1) { - const o = from + ((outletAnchorOffset - from) * i) / steps; - const e = designAt(o); - fillSlopeLength += Math.hypot((outletAnchorOffset - from) / steps, e - prevElev); - prevElev = e; - } - } + const fillSlopeLength = designAt + ? slopeLengthAlong(designAt, outletInfo.edge.offset_m, outletAnchorOffset) + : 0; // 역경사는 수평으로 클램프(수평 가능 — 사용자 확정). const outletInvert = Math.min(groundAt(outletAnchorOffset), inlet.elevation); const outletAnchor: OffsetPoint = { offset: outletAnchorOffset, elevation: outletInvert }; @@ -257,135 +260,32 @@ export function computeCulvertLayout( vertical: WallVertical | null = null, ): WallLayout | null => { const reason: BasinLayout["reason"] | null = - forceBasinReason ?? (spec.structure === "집수정" ? "cut" : null); + // 백엔드 spec의 "집수정"은 판정(ruleReason)에 이미 반영됐다 — 사용자 선택 + // revet(기슭막이 강제)를 존중하려면 여기서 다시 살리면 안 된다(2026-08-22). + forceBasinReason; if (reason) { - // 집수정 = 기슭막이와 같은 **평행사변형 벽**(+ 형식에 따라 바닥·반대측 막음). - // 형식 기본값 = ㄴ(L)형(함수 상단 basinShape). 실무 내공 1.0m(울진 돌집수정). - const shape: BasinShape = basinShape; - const innerWidth = 1.0; - // 집수정은 기슭막이와 달리 **물을 모으려고 만든 구조물**이다(2026-08-22 사용자 - // 확정) — 형태별로 별도 구성하고, 배관(invert)은 원지반 자리를 그대로 지킨다. - // · I형(공유벽뿐): 원지반이 낮아 기본 높이(관경+여유)로 노견 끝 접점이 안 - // 되면 배관은 그대로 두고 **벽을 노견 표고까지 키운다**. - // · ㄴ(L)·ㄷ형(바닥판이 원지반 대체): 벽을 키우지 않는다 — 내부 높이(바닥 - // 윗면~벽 상단)는 BASIN_INNER_HEIGHT_M(임시 1.2) 고정. - const floorThickness = BASIN_BOTTOM_CLEARANCE_M; - const roleEdge = spec.role === "inlet" ? inletInfo.edge : outletInfo.edge; - const wallHeight = - shape === "I" - ? Math.max(diameter + REVET_FREEBOARD_M, roleEdge.elevation_m - anchor.elevation) - : BASIN_INNER_HEIGHT_M; - // 벽 단면은 기슭막이와 동일(두께 0.45). 기움은 **수직 기준 반전** — 상단이 - // 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20 사용자 정정). - const wallOf = (baseOffset: number, dir: number): OffsetPoint[] => { - const topShift = -dir * REVET_LEAN_RATIO * wallHeight; - return [ - { offset: baseOffset, elevation: anchor.elevation - floorThickness }, - { offset: baseOffset + topShift, elevation: anchor.elevation + wallHeight }, - { - offset: baseOffset + topShift + dir * REVET_THICKNESS_M, - elevation: anchor.elevation + wallHeight, - }, - { - offset: baseOffset + dir * REVET_THICKNESS_M, - elevation: anchor.elevation - floorThickness, - }, - ]; - }; - // I형 벽 자리 = **상단 도로측 꼭짓점이 노견 끝 지점에 닿는다**(2026-08-22 사용자 - // 확정 — 우측 집수정이면 상단 좌측점, 좌측이면 우측점이 접점). 벽 상단이 - // 도로측으로 1:0.3 물러나므로 하단(base)은 그만큼 계류측 바깥이다. - // anchor.offset = 노견 끝(관 유입단 기준점), 상단 표고는 invert+관경+여유고라 - // 관이 토피 상한(노면−관경−토피)에 묻힌 표준 단면에서는 노견 표고와도 일치한다. - const wallBase = anchor.offset + outward * (REVET_LEAN_RATIO * wallHeight); - const parts: BasinLayout["parts"] = [{ kind: "wall", points: wallOf(wallBase, outward) }]; - // ㄴ형 = I형 + 바닥판. 바닥은 **아래가 짧은 사다리꼴**(윗변이 길고 아랫변이 - // 짧다 — 벽과 같은 1:0.3 물매가 양 끝에 붙는다). 위치는 **I형 벽 기준 반대측** - // = 도로측으로 뻗는다(2026-08-20 사용자 확정). - // 바닥은 **I형 벽의 바깥(계류측) 변에 좌측 변을 맞대고** 그 너머로 뻗는다 - // (2026-08-20 사용자 확정). 벽이 기울어 있으므로 바닥 좌측 변도 **벽 바깥면 선을 - // 그대로 따라** 기울여야 틈 없이 붙는다(높이마다 벽 x가 달라진다). - const wallOuterAt = (elevation: number): number => { - const bottom = anchor.elevation - floorThickness; - const t = (elevation - bottom) / (wallHeight + floorThickness); - return wallBase + outward * REVET_THICKNESS_M - outward * REVET_LEAN_RATIO * wallHeight * t; - }; - const floorTopInner = wallOuterAt(anchor.elevation); - const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness); - const floorOuter = floorTopInner + outward * (innerWidth + REVET_THICKNESS_M); - const floorTaper = REVET_LEAN_RATIO * floorThickness; - parts.push({ - kind: "floor", - points: [ - { offset: floorTopInner, elevation: anchor.elevation }, - { offset: floorOuter, elevation: anchor.elevation }, - { - offset: floorOuter - outward * floorTaper, - elevation: anchor.elevation - floorThickness, - }, - { offset: floorBottomInner, elevation: anchor.elevation - floorThickness }, - ], - }); - // ㄷ형이면 반대측(도로측) 막음벽을 하나 더 세운다 — 지금은 L형 기본이라 미사용. - if ((shape as BasinShape) === "U") { - parts.push({ kind: "wall", points: wallOf(anchor.offset, -outward) }); - } - // ㄴ·ㄷ형이 원지반 **안쪽**에 박히면 그만큼 절토가 필요하다(2026-08-20 사용자 ①). - // 구조물 바깥 끝 상단에서 표준단면 절토경사(1:n)로 원지반과 만나는 점까지 긋는다. - let basinCut: BasinLayout["cutLine"] = null; - if ((shape as BasinShape) !== "I") { - const outerTop = { offset: floorOuter, elevation: anchor.elevation }; - if (groundAt(floorOuter) > anchor.elevation + 0.05) { - const cutRatio = section.design?.cut_slope_ratio ?? 1.0; - for (let h = 0.05; h <= 20; h += 0.05) { - const probe = floorOuter + outward * cutRatio * h; - if (groundAt(probe) <= anchor.elevation + h) { - basinCut = { - from: outerTop, - to: { offset: probe, elevation: anchor.elevation + h }, - }; - break; - } - } - } - } - basin = { - shape, - parts, - // 라벨 자리 = **I형 벽 하단**(2026-08-20 사용자). 상단에 두면 노면·계획선과 - // 겹친다 — 벽 바닥 변 중앙에 두고 그리기 쪽에서 아래로 내려 붙인다. - label: { - offset: wallBase + outward * (REVET_THICKNESS_M / 2), - elevation: anchor.elevation - floorThickness, - }, + // 집수정 구성은 분리 파일(_Cross_Culvert_Basin.ts — 700줄 제한)이 맡는다. + const built = buildBasin({ + anchor, + outward, + shape: basinShape, reason, - cutLine: basinCut, - }; - // 성토 사면선은 **벽 상단 도로측 꼭지점(교점)**에서 끊는다 — 바닥 끝을 기준으로 - // 두면 사면선이 집수정을 뚫고 들어간다(2026-08-20 사용자 ①). - const wallTopInner = wallBase - outward * REVET_LEAN_RATIO * wallHeight; + diameterM: diameter, + edge: spec.role === "inlet" ? inletInfo.edge : outletInfo.edge, + groundAt, + cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, + }); + basin = built.basin; + basinPipeEnd = built.pipeEnd; + endFaces[spec.role] = built.endFace; + // 성토 사면선은 벽 상단 도로측 꼭짓점에서 끊고, 끝단 표고도 거기 맞춘다. if (outward > 0) { - trimMax = wallTopInner; - trimMaxElevation = anchor.elevation + wallHeight; + trimMax = built.trimOffset; + trimMaxElevation = built.trimElevation; } else { - trimMin = wallTopInner; - trimMinElevation = anchor.elevation + wallHeight; + trimMin = built.trimOffset; + trimMinElevation = built.trimElevation; } - // 유입 배관 위치는 **현재 유지**(2026-08-22 사용자 확정) — 관 유입단은 종전처럼 - // 노견 끝에서 내공 1.0m 자리다. 벽이 노견에 붙으면서 관 끝이 벽을 지나 집수정 - // 안으로 들어가므로, 마감면은 벽 변을 복사하지 않고 **관 끝 자리에서 벽과 나란한 - // 기울기**로 자른다(벽 변에 자르면 관이 벽 앞에서 끊겨 위치가 바뀐다). - basinPipeEnd = { - offset: anchor.offset + outward * innerWidth, - elevation: anchor.elevation, - }; - endFaces[spec.role] = { - base: { offset: basinPipeEnd.offset, elevation: anchor.elevation - floorThickness }, - direction: { - offset: -outward * REVET_LEAN_RATIO * (wallHeight + floorThickness), - elevation: wallHeight + floorThickness, - }, - }; return null; } // 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면 @@ -568,23 +468,6 @@ export function computeCulvertLayout( }; }; deriveAxis(); - /** 교점을 마감면 **선분 안**으로 자른다 — 선분 밖(벽 하단 아래 등)으로 새지 않게. */ - const clampToFace = ( - point: OffsetPoint, - face: { base: OffsetPoint; direction: { offset: number; elevation: number } }, - ): OffsetPoint => { - const dot = - (point.offset - face.base.offset) * face.direction.offset + - (point.elevation - face.base.elevation) * face.direction.elevation; - const len2 = - face.direction.offset * face.direction.offset + - face.direction.elevation * face.direction.elevation; - const t = len2 > 1e-12 ? Math.min(Math.max(dot / len2, 0), 1) : 0; - return { - offset: face.base.offset + face.direction.offset * t, - elevation: face.base.elevation + face.direction.elevation * t, - }; - }; const endCorners = (role: "inlet" | "outlet", endPoint: OffsetPoint): PipeEnd => { const fallback: PipeEnd = { bottom: endPoint, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index bde85fc7..45b71a7a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -308,3 +308,39 @@ export function solveWallVertical( const floatGap = Math.max(0, desiredTop - limitHeight - groundBase); return { height, baseElevation: groundBase + floatGap, floatGapM: floatGap }; } + +/** 교점을 마감면 **선분 안**으로 자른다 — 선분 밖(벽 하단 아래 등)으로 새지 않게. */ +export function clampToFace( + point: OffsetPoint, + face: { base: OffsetPoint; direction: { offset: number; elevation: number } }, +): OffsetPoint { + const dot = + (point.offset - face.base.offset) * face.direction.offset + + (point.elevation - face.base.elevation) * face.direction.elevation; + const len2 = + face.direction.offset * face.direction.offset + + face.direction.elevation * face.direction.elevation; + const t = len2 > 1e-12 ? Math.min(Math.max(dot / len2, 0), 1) : 0; + return { + offset: face.base.offset + face.direction.offset * t, + elevation: face.base.elevation + face.direction.elevation * t, + }; +} + +/** 설계선을 따라 [from → to] 구간의 경사길이(m)를 잰다 — 성토사면 길이 측정용. */ +export function slopeLengthAlong( + designAt: (offset: number) => number, + from: number, + to: number, +): number { + const steps = 60; + let length = 0; + let prevElev = designAt(from); + for (let i = 1; i <= steps; i += 1) { + const o = from + ((to - from) * i) / steps; + const e = designAt(o); + length += Math.hypot((to - from) / steps, e - prevElev); + prevElev = e; + } + return length; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index b8704528..1db1074b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -72,8 +72,9 @@ export interface BasinLayout { parts: Array<{ kind: "wall" | "floor"; points: OffsetPoint[] }>; label: OffsetPoint; /** 자동 전환 사유 — 툴팁·라벨에 쓴다(2026-08-22 재정의: 성토사면 ≤3m 기준). - * "cut" = 절토측 유입(성토사면 없음), "short" = 성토사면 3m 이하. */ - reason: "cut" | "short"; + * "cut" = 절토측 유입(성토사면 없음), "short" = 성토사면 3m 이하, + * "manual" = 규칙상 기슭막이 자리지만 사용자가 집수정을 선택. */ + reason: "cut" | "short" | "manual"; /** 집수정이 원지반 안으로 들어갈 때의 절토선(구조물 바깥 끝 → 지반 교차점). */ cutLine: { from: OffsetPoint; to: OffsetPoint } | null; } diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 49906d1a..2e6d925c 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -10,7 +10,7 @@ * 우측 상단, 면적표는 중상단이라 이 창은 **좌측 하단**에 둔다. * ========================================================================== */ -import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { L } from "./B06_Section_UI_Section_Common"; /** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */ @@ -25,6 +25,12 @@ export interface StructurePanelDeps { pipeLengthM: () => number | null; /** 창을 닫는다 = 구조물 선택 해제. */ close: () => void; + /** 유입측 구조물 형식(드롭다운 값 — 2026-08-22 사용자). */ + structureFor: () => InletStructureChoice; + /** 유입측 구조물 형식 변경 — 카드를 다시 그린다. */ + setStructure: (value: InletStructureChoice) => void; + /** ◀/▶ 이동 가능 여부 — 집수정(자리 고정)은 숨긴다. */ + canNudge: (key: RevetKey) => boolean; } export interface StructurePanelHandle { @@ -92,13 +98,38 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan ), ); + // 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ. + const structureRow = document.createElement("label"); + structureRow.className = "b06-structure-panel__struct"; + const structureLabel = document.createElement("span"); + structureLabel.textContent = L("B06_Cross_Struct_Label"); + const select = document.createElement("select"); + select.className = "b06-structure-panel__select"; + const OPTIONS: Array<[InletStructureChoice, string]> = [ + ["auto", L("B06_Cross_Struct_Auto")], + ["revet", L("B06_Cross_Struct_Revet")], + ["I", L("B06_Cross_Struct_I")], + ["L", L("B06_Cross_Struct_L")], + ["U", L("B06_Cross_Struct_U")], + ]; + for (const [optionValue, label] of OPTIONS) { + const option = document.createElement("option"); + option.value = optionValue; + option.textContent = label; + select.append(option); + } + select.addEventListener("change", () => { + deps.setStructure(select.value as InletStructureChoice); + }); + structureRow.append(structureLabel, select); + const closeButton = makeButton("✕", L("B06_Cross_Revet_Close"), () => deps.close()); closeButton.classList.add("b06-structure-panel__close"); const head = document.createElement("div"); head.className = "b06-structure-panel__head"; head.append(title, closeButton); - root.append(head, value, buttons); + root.append(head, value, structureRow, buttons); return { root, @@ -106,7 +137,16 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan current = key; root.classList.toggle("is-hidden", key === null); if (!key) return; - title.textContent = L(key === "inlet" ? "B06_Cross_Revet_Inlet" : "B06_Cross_Revet_Outlet"); + title.textContent = + key === "outlet" + ? L("B06_Cross_Revet_Outlet") + : deps.canNudge("inlet") + ? L("B06_Cross_Revet_Inlet") + : L("B06_Cross_Struct_InletBasin"); + // 형식 선택은 유입측에서만, ◀/▶/↺는 자리를 옮길 수 있는 구조물(기슭막이)만. + structureRow.classList.toggle("is-hidden", key !== "inlet"); + if (key === "inlet") select.value = deps.structureFor(); + 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 8dda79df..fb32ad8a 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -25,6 +25,7 @@ import { } from "./B06_Section_UI_Cross_Design"; import { showToast } from "@ui/ui_template_elements"; import { appendCulvertOverlay, computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert"; +import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert"; import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; @@ -174,6 +175,12 @@ export interface RevetOffsetControl { reset: (chainageM: number, role: RevetKey) => void; } +/** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22 사용자). 세션 보관은 Page가 한다. */ +export interface InletStructureControl { + valueFor: (section: CrossSection) => InletStructureChoice; + set: (chainageM: number, value: InletStructureChoice) => void; +} + export function createCrossSectionCard( section: CrossSection, selected: boolean, @@ -194,6 +201,8 @@ export function createCrossSectionCard( stationWidth?: StationWidthControl, /** 기슭막이 X 자리 제어 — 있으면 벽이 선택 가능해지고 선택 시 ◀/▶/↺가 뜬다. */ revetOffset?: RevetOffsetControl, + /** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22). */ + inletStructure?: InletStructureControl, ): CrossCardElement { // 이 카드의 실효 표시 반폭 — 개별값이 전역 반폭보다 우선한다(2026-08-06). const effectiveHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth; @@ -217,6 +226,8 @@ export function createCrossSectionCard( let showRevetControl: (visible: boolean) => void = () => undefined; /** 지금 그린 관 길이(m) — 조정창이 "관 길이 8m"으로 적는다. 배수관 측점이 아니면 null. */ let culvertPipeLengthM: number | null = null; + /** 유입측이 집수정인가 — 조정창이 ◀/▶ 표시 여부·제목을 가른다. */ + let culvertInletIsBasin = false; /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 @@ -464,6 +475,7 @@ export function createCrossSectionCard( outlet: revetOffset.shiftFor(section, "outlet"), } : undefined, + inletStructure?.valueFor(section), ); // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. appendPavementOverlay(plotLayer, section.design, x, toDisplayY); @@ -487,6 +499,7 @@ export function createCrossSectionCard( } // 배수관 측점 세트(배관·기슭막이·보호공) — 기존 도형 위에 추가만 한다(2026-08-19). culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null; + culvertInletIsBasin = !!culvertLayout?.basin; // 요청한 이동량이 한계에 걸려 잘렸으면 그 값으로 되돌려 담는다 — 안 그러면 눌러도 // 안 움직이는데 창의 숫자만 계속 커진다(2026-08-21 사용자 지적). // 잘린 순간에는 **왜 안 움직였는지 토스트로 알린다**(2026-08-22 사용자 요청 — @@ -604,6 +617,10 @@ export function createCrossSectionCard( close: () => { if (activeRevet) toggleRevet(activeRevet); }, + structureFor: () => inletStructure?.valueFor(section) ?? "auto", + setStructure: (value) => inletStructure?.set(section.chainage_m, value), + // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다. + canNudge: (key) => key === "outlet" || !culvertInletIsBasin, }); showRevetControl = (visible) => panel.show(visible ? activeRevet : null); showRevetControl(activeRevet !== null); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index cbdc13d6..701a3b5b 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -410,6 +410,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { rockBoundaryControl, stationWidthControl, revetOffsetControl, + stationControls.inletStructure, ); // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 80251447..0091d36c 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -6,22 +6,27 @@ * ========================================================================== */ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; -import type { RevetOffsetControl, StationWidthControl } from "./B06_Section_UI_Cross_View"; +import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { + InletStructureControl, + RevetOffsetControl, + StationWidthControl, +} from "./B06_Section_UI_Cross_View"; /** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */ export interface StationControlDeps { - sessionKey: (kind: "crossw" | "revetx") => string | null; + sessionKey: (kind: "crossw" | "revetx" | "inletstruct") => string | null; refreshCard: (chainageM: number) => void; detail: () => SectionDetailResponse | null; crossHalfWidth: () => number | undefined; sampledHalfWidth: () => number; } -/** 반폭·기슭막이 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */ +/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */ export interface StationControls { stationWidth: StationWidthControl; revetOffset: RevetOffsetControl; + inletStructure: InletStructureControl; widths: Map; load: () => void; /** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */ @@ -166,13 +171,56 @@ export function createStationControls(deps: StationControlDeps): StationControls deps.refreshCard(chainageM); }, }; + /* ── 유입측 구조물 형식(2026-08-22 사용자 — 드롭다운) ──────────────── + * auto(규칙)/revet(기슭막이+배관)/I/L/U(집수정 형식). 세션에만 담는다. */ + const inletStructures = new Map(); + const structSessionKey = (): string | null => deps.sessionKey("inletstruct"); + + function loadInletStructures(): void { + inletStructures.clear(); + const key = structSessionKey(); + if (!key) return; + try { + const raw = window.sessionStorage.getItem(key); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + Object.entries(parsed).forEach(([chainage, value]) => { + if (["auto", "revet", "I", "L", "U"].includes(value)) inletStructures.set(chainage, value); + }); + } catch { + /* 손상된 세션 값은 무시 — auto(규칙)로 재시작. */ + } + } + + function persistInletStructures(): void { + const key = structSessionKey(); + if (!key) return; + try { + window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(inletStructures))); + } catch { + /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ + } + } + + const inletStructureControl: InletStructureControl = { + valueFor: (section) => inletStructures.get(section.chainage_m.toFixed(2)) ?? "auto", + set: (chainageM, value) => { + if (value === "auto") inletStructures.delete(chainageM.toFixed(2)); + else inletStructures.set(chainageM.toFixed(2), value); + persistInletStructures(); + deps.refreshCard(chainageM); + }, + }; + return { stationWidth: stationWidthControl, revetOffset: revetOffsetControl, + inletStructure: inletStructureControl, widths: stationWidths, load: () => { loadStationWidths(); loadRevetShifts(); + loadInletStructures(); }, applyGlobalWidth: (requested, chainages) => { stationWidths.clear(); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 5a2b1fc9..53f2fc23 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -27,6 +27,7 @@ import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas"; import { createCrossSectionCard, + type InletStructureControl, type RevetOffsetControl, crossCardNaturalHeight, type CrossCardElement, @@ -173,6 +174,8 @@ export function createSectionView( stationWidth?: StationWidthControl, /** 기슭막이 X 자리 제어(2026-08-21) — 벽을 골라 0.1m씩 민다. */ revetOffset?: RevetOffsetControl, + /** 유입측 구조물 형식 선택(2026-08-22) — 조정창 드롭다운. */ + inletStructure?: InletStructureControl, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; @@ -444,6 +447,7 @@ export function createSectionView( selectArea, stationWidth, revetOffset, + inletStructure, ); /** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */ diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 3ec1658d..0d2fe82e 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -606,6 +606,33 @@ gap: 2px; } +.b06-structure-panel__buttons.is-hidden { + display: none; +} + +/* 유입 구조물 형식 드롭다운(2026-08-22 사용자) — 라벨 + select 한 줄. */ +.b06-structure-panel__struct { + display: flex; + align-items: center; + gap: var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b06-structure-panel__struct.is-hidden { + display: none; +} + +.b06-structure-panel__select { + flex: 1; + padding: 1px var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text); + font-size: var(--text-caption); +} + .b06-structure-panel__btn { padding: 0 var(--spacing-8); border: 1px solid var(--color-border); diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index d374df1b..524a1bf1 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -250,6 +250,13 @@ export const ui_locales_b2 = { B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"], B06_Cross_Revet_Inlet: ["유입 기슭막이", "Inlet revetment"], B06_Cross_Revet_Outlet: ["유출 기슭막이", "Outlet revetment"], + B06_Cross_Struct_Label: ["구조물 형식", "Structure type"], + B06_Cross_Struct_Auto: ["자동(규칙)", "Auto (by rule)"], + B06_Cross_Struct_Revet: ["기슭막이+배관", "Revetment + pipe"], + B06_Cross_Struct_I: ["집수정 I형", "Catch basin — I"], + B06_Cross_Struct_L: ["집수정 ㄴ형", "Catch basin — L"], + B06_Cross_Struct_U: ["집수정 ㄷ형", "Catch basin — U"], + B06_Cross_Struct_InletBasin: ["유입 집수정", "Inlet catch basin"], B06_Cross_Revet_Limit_Inward: [ "기슭막이 안쪽 한계 — 노견(도로) 안쪽으로는 들어갈 수 없습니다.", "Revetment inner limit — cannot move inside the road shoulder.",