/* ============================================================================= * B06_Section_UI_Cross_Revetment.ts * 독립 기슭막이(배관과 무관) — 배관 세트 기슭막이와 **같은 배치·같은 산식·같은 도형**. * 배관만 없을 뿐, 벽 자리·이동·한계는 배관 유출 벽과 한 몸으로 푼다(2026-08-28 사용자: * "배관용에서 배관만 빼면 대부분 동일해야"). * * · 자리(2026-08-29 사용자 개정): 조작은 **순수 좌우(x)·순수 상하(d)** 0.1m다. * x = 자동 자리에서의 수평 이동, d = 노견에서 잰 수직 하강. 성토선 각도는 유지하고 * **노견 확장 구간**이 길이를 흡수한다. 한계는 노견 안쪽 금지·확장 0(각도 유지)·매몰 금지. * 배관 기준벽(`placePipeWall`)의 이동은 손대지 않는다. * · 실제 적용값(한계 절삭 후)은 `appliedAdjust`로 돌려주고, 화면이 조작 저장소에 되받는다 * — 눌러도 안 움직이는데 숫자만 커지는 것 방지(배관과 같은 syncApplied 규칙). * · 다단: 배관과 **같은 함수** `buildExtrasAt`. 그리기(폴리곤·이음선·돌·성토부선)는 공용 * `_Cross_Wall`. 노견→벽 성토선은 designTrim의 slope로 그린다. * * 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { FILL_SLOPE_RATIO_MIN, PIPE_CONNECT_GRADE, PIPE_WALL_DEFAULT_RUN_M, REVET_EMBED_DEPTH_M, REVET_THICKNESS_M, revetHeightLimit, } from "./B06_Section_UI_Cross_Culvert_Const"; import { leanOf } from "./B06_Section_UI_Cross_Lean"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; import { ZERO_ADJUST, type CulvertDesignTrim, type OutletFillSegment, type WallAdjust, type WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation"; import { appendPlanLine, buildRevetWallGeometry, drawRevetWall } from "./B06_Section_UI_Cross_Wall"; const SVG_NS = "http://www.w3.org/2000/svg"; /** 서버가 얹어 주는 독립 기슭막이 제원(`section.revetment`, 구조물 정본 D군). */ export interface RevetmentSpec { structure_id: string | null; type_id: string; name: string; start_m: number; end_m: number; anchor_m: number; form?: string | null; height_m?: number | null; /** 사용자가 고른 설치 측 — "좌" | "우". */ side?: string | null; /** 기초 축 — "기초유" | "기초버림". **비어 있으면 터파기를 안 그린다**(근거 없음). */ foundation?: string | null; /** 단 수(다단). 1이면 단일 벽. */ tiers?: number | null; /** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */ lift_m?: number | null; shift_m?: number | null; /** 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 성토/절토 · 전면 기울기(2026-09-14 B5). */ face_role?: string | null; face_slope_ratio?: number | string | null; } /** 표준경사 표를 읽는 벽 종류 → 형태. B08 이 돌쌓기 식으로 세는 것만(큰돌쌓기·옹벽은 종전 0.3). */ const TABLE_FORM_BY_TYPE: Record = { masonry_wet: "돌쌓기(찰)", masonry_dry: "돌쌓기(메)", }; /** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */ export interface RevetmentAdjust { x?: number; d?: number | null; h?: number | null; } export interface RevetPoint { offset: number; elevation: number; } /** 한 단(段) — 3D 스윕용 폴리곤(배관 벽과 같은 합성 단면). */ export interface RevetmentTier { polygon: RevetPoint[]; } export interface RevetmentLayout { side: "left" | "right"; /** 1단 벽 높이(m) — **바닥~상단**. 조정창 높이 표시·조작의 기준값. */ heightM: number; /** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 센다(재렌더 중복 방지). */ chainageM: number; /** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */ requestedTiers: number; /** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */ top: RevetPoint; /** 벽 목록(1단 + 다단) — 배관 벽과 같은 `WallLayout`, 그리기가 그대로 쓴다. */ walls: WallLayout[]; /** 기초 축 — 저장 칸과 **같은 글자**(`foundation`). 터파기 그림이 이 값으로 갈린다. */ foundation: string | null; /** 벽 사이·벽 아래 성토부선(배관 다단과 같은 체계). */ fillSegments: OutletFillSegment[]; /** 성토 설계선을 벽 상단에서 끊고 노견→벽 성토선을 대신 그리는 트림. */ designTrim: CulvertDesignTrim; /** 한계 절삭 후 **실제 적용된** 1단 조작값 — 화면이 조작 저장소에 되받는다. */ appliedAdjust: WallAdjust; /** 3D 스윕용 단 목록 — 벽 폴리곤 그대로. */ tiers: RevetmentTier[]; /** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */ span: { beforeM: number; afterM: number }; } /** 형태(구조물 옵션) → 재질(높이 한계·라벨). 메 계열은 dry, 찰=wet, 콘크리트=concrete. */ function materialOfForm(form?: string | null): RevetMaterial { if (!form) return "dry"; if (form.includes("찰")) return "wet"; if (form.includes("콘크리트")) return "concrete"; return "dry"; // 메쌓기·돌망태·통나무·바자 — 메 계열(2.0m 한계) } /** 유효 지반 샘플의 바깥 한계 offset(설치 측). 없으면 null. */ function sampleLimit(section: CrossSection, outward: number): number | null { const offsets = section.samples .filter((s) => s.valid !== false && typeof s.offset_m === "number") .map((s) => s.offset_m as number); if (!offsets.length) return null; return outward > 0 ? Math.max(...offsets) : Math.min(...offsets); } /** * 노면 횡단물매를 **바깥 방향 1m당 표고 변화**로 돌려준다(하향이면 음수). * 노면은 측구 쪽으로 내려가는 단일 평면이라(엔진 `road_z`), 노견을 밖으로 연장하면 * 그 평면을 그대로 타고 가야 각도가 맞는다(2026-08-29 사용자). * 1순위는 **실제 그려진 노견 구간**(차도 끝 → 노면 끝)의 기울기 — 부호를 추측하지 * 않는다. 노견 폭이 0이면 `cross_slope_pct`를 측구 방향 부호로 쓴다. */ function roadSlopePerOutward( design: NonNullable, side: "left" | "right", outward: number, ): number { const edge = design.road_edges?.[side]; const inner = design.carriageway_edges?.[side]; if (edge && inner) { const run = (edge.offset_m - inner.offset_m) * outward; if (run > 1e-6) return (edge.elevation_m - inner.elevation_m) / run; } const pct = Number(design.cross_slope_pct); if (!Number.isFinite(pct)) return 0; return (pct / 100) * (design.ditch_side === side ? -1 : 1); } /** * 독립 기슭막이 단면 — 제원·높이·설계선이 없거나 자리를 못 찾으면 null. * 1단 벽 = 배관 유출 벽과 같은 `placePipeWall`+`buildRevetWallGeometry`, * 다단 = 배관과 같은 `buildExtrasAt`. */ export function computeRevetmentLayout( section: CrossSection, adjust?: RevetmentAdjust | null, ): RevetmentLayout | null { const spec = section.revetment; const design = section.design; if (!spec || !design) return null; const requestedHeight = Number(adjust?.h ?? spec.height_m); if (!Number.isFinite(requestedHeight) || requestedHeight <= 0) return null; // 설치 측이 비어 있으면 **성토가 나는 쪽**에 세운다 — 좌측 「구조물 배치」의 C군 벽 // (옹벽·돌쌓기 등)에는 설치 측 칸이 없기 때문이다(2026-09-06). 양측 절토면 벽이 설 // 자리가 없으므로 그리지 않는다(면적도 그대로). const fillSide = (): "left" | "right" | null => { if (design.section_mode === "left_cut") return "right"; if (design.section_mode === "right_cut") return "left"; if (design.section_mode === "both_fill") return "left"; return null; }; const side = spec.side === "우" ? "right" : spec.side === "좌" ? "left" : fillSide(); if (!side) return null; const outward = side === "left" ? 1 : -1; const edge = design.road_edges?.[side]; const groundAt = groundInterpolator(section.samples); const limit = sampleLimit(section, outward); if (!edge || !groundAt || limit === null) return null; // 높이 기준 = **벽 바닥~상단**(근입 0.5 포함 — 배관·추가 벽과 같은 기준, // 2026-08-29 사용자). 재질(형태) 한계도 이 기준으로 자른다. const pureHeight = Math.min( Math.max(requestedHeight, REVET_EMBED_DEPTH_M + 0.1), revetHeightLimit(spec.form), ); /** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */ const height = pureHeight - REVET_EMBED_DEPTH_M; // 전면 기울기 — B08 이 그 벽을 셀 때와 같은 칸(종류·요청 높이·단면유형·설치 측·사용자 칸)으로 // 표준경사를 고름(2026-09-14 B5). 높이는 형태 한계로 자르기 **전** 값 — B08 은 저장 높이를 씀. const lean = leanOf({ form: TABLE_FORM_BY_TYPE[spec.type_id] ?? (spec.type_id === "revetment" ? spec.form : null), height_m: requestedHeight, section_mode: design.section_mode, side: spec.side, face_role: spec.face_role, face_slope_ratio: spec.face_slope_ratio, }); const roadSlope = roadSlopePerOutward(design, side, outward); const autoOffset = edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2); // 자동 자리 = 성토사면(1:1.2) 위 기본 지점 — 배관 유출 벽과 같은 기준값. const autoRun = PIPE_WALL_DEFAULT_RUN_M; /** * 조작 축(2026-08-29 사용자): x = **순수 좌우(m)**, d = **노견에서 잰 순수 수직 * 하강(m)**. 성토선 각도는 그대로 두고 **노견 확장 구간**이 길이를 흡수한다 * (확장량 t는 아래 폴리라인에서 역산 — t ≥ 0이 각도 유지의 한계다). */ const defaultDrop = autoRun / FILL_SLOPE_RATIO_MIN; /** 그 자리에 벽을 세울 수 있나 — 매몰(원지반 아래 daylight 없음) 금지. */ const placeable = (xShift: number, drop: number): boolean => { const anchor = autoOffset + outward * (autoRun + xShift); const invert = edge.elevation_m - drop - height; return ( invert >= groundAt(anchor) - 0.01 || slopedCrossing( { offset: anchor, elevation: invert }, outward, -PIPE_CONNECT_GRADE, groundAt, limit, ) !== null ); }; const requestedX = Math.max(adjust?.x ?? 0, 0); /** 각도(1:1.2)를 지키려면 그만큼은 밖으로 나가 있어야 하는 최소 수평 자리. */ const xFloor = (drop: number): number => Math.max(0, FILL_SLOPE_RATIO_MIN * drop - autoRun); let appliedDrop = Math.max(adjust?.d ?? defaultDrop, 0); let appliedX = Math.max(requestedX, xFloor(appliedDrop)); for (let pass = 0; pass < 400 && !placeable(appliedX, appliedDrop); pass += 1) { // 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우). if (appliedDrop > 1e-9) { appliedDrop = Math.max(0, appliedDrop - 0.05); appliedX = Math.max(Math.min(appliedX, requestedX), xFloor(appliedDrop)); } else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05); else break; } appliedX = Math.round(appliedX * 10) / 10; appliedDrop = Math.round(appliedDrop * 10) / 10; const anchorOffset = autoOffset + outward * (autoRun + appliedX); const baseElevation = edge.elevation_m - appliedDrop - height; const floatGapM = Math.max(0, baseElevation - groundAt(anchorOffset)); const material = materialOfForm(spec.form); const lengthM = Number.isFinite(spec.end_m - spec.start_m) ? spec.end_m - spec.start_m : null; const tier1 = buildRevetWallGeometry({ anchor: { offset: anchorOffset, elevation: baseElevation }, outward, height, material, role: "outlet", form: spec.form, lengthM, floatGapM, lean, }); // 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07). // 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고, // 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0 이어서 까닭을 알 길이 없었다. const floorNow = xFloor(appliedDrop); if (requestedX > 0.005 && floorNow > requestedX + 0.005) { tier1.shiftFloorM = Math.round(floorNow * 100) / 100; } // 다단 — 배관과 같은 함수. 1단 벽 전면 하단 꼭짓점을 성토부선 시작점으로. const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1)); const extras = requestedTiers > 1 ? buildExtrasAt(tier1.bottomFront, { startBottomElevation: tier1.bottomBack.elevation, outward, groundAt, limitOffset: limit, adjusts: Array.from({ length: requestedTiers - 1 }, () => ({ ...ZERO_ADJUST })), // 단들도 1단과 같은 형태로 선다 — 안 넘기면 형태가 비어 늘 메쌓기로 // 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우). ownerForm: spec.form, equalize: false, leanFor: (form, pureHeightM) => leanOf({ form, height_m: pureHeightM, section_mode: design.section_mode, side: spec.side, }), }) : { walls: [], segments: [], appliedAdjusts: [], addable: false }; const walls: WallLayout[] = [tier1, ...extras.walls]; const jt = tier1.topJoint; // 노견 → (물매를 탄 노견 연장) → 벽 이음선 상단점. 연장 구간은 노면 물매 그대로 가고, // 꺾임점에서 성토 물매 1:1.2로 벽 상단에 닿는다(2026-08-29 사용자 확정). // D = 노견에서 벽 상단까지 바깥 방향 거리, R0 = 노견과 벽 상단의 낙차 // (D − t) = 1.2 × (R0 + roadSlope × t) → t = (D − 1.2·R0) / (1 + 1.2·roadSlope) const edgePoint = { offset: edge.offset_m, elevation: edge.elevation_m }; const D = (jt.offset - edge.offset_m) * outward; const R0 = edge.elevation_m - jt.elevation; const denominator = 1 + FILL_SLOPE_RATIO_MIN * roadSlope; const breakRun = R0 > 1e-6 && D > 1e-6 && Math.abs(denominator) > 1e-9 ? Math.min(D, Math.max(0, (D - FILL_SLOPE_RATIO_MIN * R0) / denominator)) : 0; const slope = { points: breakRun > 1e-6 ? [ edgePoint, { offset: edge.offset_m + outward * breakRun, elevation: edge.elevation_m + roadSlope * breakRun, }, jt, ] : [edgePoint, jt], }; const designTrim: CulvertDesignTrim = outward > 0 ? { minOffset: -Infinity, maxOffset: jt.offset, maxElevation: jt.elevation, maxSlope: slope } : { minOffset: jt.offset, maxOffset: Infinity, minElevation: jt.elevation, minSlope: slope }; return { side, heightM: pureHeight, chainageM: Number(section.chainage_m) || 0, requestedTiers, top: jt, walls, foundation: typeof spec.foundation === "string" ? spec.foundation.trim() || null : null, fillSegments: extras.segments, designTrim, appliedAdjust: { x: appliedX, d: appliedDrop, h: adjust?.h ?? null, m: null }, tiers: walls.map((wall) => ({ polygon: wall.points })), span: { beforeM: Math.max(spec.anchor_m - spec.start_m, 0), afterM: Math.max(spec.end_m - spec.anchor_m, 0), }, }; } /** 자리가 없어 못 세운 단이 있으면 **한 번만** 알린다 — 카드마다 띄우면 화면이 덮인다. */ const shortfallStations = new Set(); let shortfallTimer = 0; let shortfallLast = ""; function noticeShortfall(layout: RevetmentLayout): void { if (layout.walls.length >= layout.requestedTiers) return; shortfallStations.add(Math.round(layout.chainageM * 100)); window.clearTimeout(shortfallTimer); shortfallTimer = window.setTimeout(() => { const message = `기슭막이 ${layout.requestedTiers}단 중 ${layout.walls.length}단만 세울 수 있습니다` + `(측점 ${shortfallStations.size}곳) — 아래 자리가 원지반에 묻힙니다. ` + "기준 벽을 사면 위로 올린 뒤(▲) 단을 늘리세요."; shortfallStations.clear(); if (message === shortfallLast) return; shortfallLast = message; showToast(message, "warning"); }, 400); } /** 횡단 카드에 독립 기슭막이(1단+다단)를 그린다 — 배관 벽과 같은 도형. 그렸으면 setter. */ export function appendRevetmentOverlay( svg: SVGElement, layout: RevetmentLayout | null, x: (offset: number) => number, y: (elevation: number) => number, onSelect?: () => void, ): ((active: boolean) => void) | null { if (!layout || !layout.walls.length) return null; noticeShortfall(layout); // 성토부선(벽 사이·벽 아래) 먼저 — 벽 밑에 깔린다. for (const segment of layout.fillSegments) { if (segment.kind === "cut") continue; appendPlanLine( svg, segment.points, x, y, `독립 기슭막이 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + (segment.overLimit ? " (법정 5m 이상 — 단 추가 검토)" : " (법정 5m 이내)") + ` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`, ); } // 터파기는 벽보다 **먼저** — 파선이 벽 밑에 깔린다. 기초 축이 비어 있으면 안 그린다. const trenchFoundation = foundationChoice(layout.foundation); for (const wall of layout.walls) { appendWallTrench(svg, wall, trenchFoundation, x, y); } const drawn: SVGPolygonElement[] = []; layout.walls.forEach((wall, index) => { const keyId = index === 0 ? "own" : `own-extra${index - 1}`; const tooltip = `독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` + `(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사), 높이 한계 ${revetHeightLimit( wall.form, ).toFixed(1)}m — 교본 7-3)` + (wall.floatGapM > 0.01 ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도` : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m`) + (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "") + // 「좌우로 옮겨도 안 움직인다」의 까닭 두 가지 — 배관 벽 툴팁과 같은 문구다 // (2026-09-07). 독립 벽 툴팁은 따로 만들어져 있어 여태 어느 쪽도 안 떴다. (wall.shiftBlockedM ? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)` : "") + (wall.shiftFloorM ? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` + ` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임` : ""); const shape = drawRevetWall(svg, wall, x, y, "b06-chart__culvert-revet", tooltip, keyId); if (onSelect) shape.classList.add("is-selectable"); drawn.push(shape); }); // 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를 // 달면 가운데 클릭이 돌에 먹힌다(배관 벽과 같은 규칙 — 판정면만 위로). if (onSelect) { for (const wall of layout.walls) { const hit = document.createElementNS(SVG_NS, "polygon"); hit.setAttribute( "points", wall.points.map((p) => `${x(p.offset)},${y(p.elevation)}`).join(" "), ); hit.setAttribute("class", "b06-chart__culvert-revet-hit"); hit.addEventListener("click", (event) => { event.stopPropagation(); onSelect(); }); svg.append(hit); } } return (active: boolean): void => { for (const polygon of drawn) polygon.classList.toggle("is-active", active); }; }