/* ============================================================================= * B06_Section_UI_Cross_Culvert.ts * 배수관 측점 횡단 카드의 배수관 세트(배관·기슭막이·보호공) **오버레이 그리기**. * * 기하 계산은 `B06_Section_UI_Cross_Culvert_Geom.ts`가 맡는다(700줄 제한 분리). * 여기서는 계산 결과(`CulvertLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지. * * 그리는 순서 = 시공·계산 순서(2026-08-20 사용자 확정): * ① 유출구측 구조물(기슭막이·추가 기슭막이) → ② 유입구측 구조물(기슭막이 또는 * 집수정) → ③ 배관 → ④ 유출구측 성토부선(보호공 삭제 — 2026-08-22) * ========================================================================== */ import { REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, pipeWallThicknessM, revetHeightLimit, } from "./B06_Section_UI_Cross_Culvert_Geom"; import type { BasinShape, CulvertLayout, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Geom"; // 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가 // 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다. export { MIN_PIPE_COVER_M, computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert_Geom"; export type { BasinLayout, BasinShape, CulvertDesignTrim, CulvertLayout, EndFace, InletStructureChoice, } from "./B06_Section_UI_Cross_Culvert_Geom"; const SVG_NS = "http://www.w3.org/2000/svg"; function polygon( points: Array<[number, number]>, className: string, tooltip: string, ): SVGPolygonElement { const shape = document.createElementNS(SVG_NS, "polygon"); shape.setAttribute("points", points.map(([px, py]) => `${px},${py}`).join(" ")); shape.setAttribute("class", className); const title = document.createElementNS(SVG_NS, "title"); title.textContent = tooltip; shape.append(title); return shape; } /** 기슭막이 선택 키 — 측점 안에서 어느 벽인지 가린다. 추가 벽은 `extra{n}`. */ export type RevetKey = "inlet" | "outlet" | `extra${number}`; /** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */ export type RevetHighlightSetter = (key: RevetKey | null) => void; /** * 배수관 세트 오버레이 — computeCulvertLayout 결과를 그린다. * * `onSelectRevet`이 오면 기슭막이 폴리곤이 **선택 대상**이 된다(2026-08-21 사용자 ①). * 클릭은 여기서 멈춘다 — 밑에 깔린 절·성토 밴드와 카드 선택으로 번지면 강조가 다른 * 것으로 바뀌거나 카드가 다시 그려진다(**구조물 선택이 면적 선택보다 우선**). */ export function appendCulvertOverlay( layer: SVGElement, layout: CulvertLayout, x: (offset: number) => number, toDisplayY: (elevation: number) => number, onSelectRevet?: (key: RevetKey) => void, ): RevetHighlightSetter { const { culvert, pipe, pipeCorners } = layout; const diameter = culvert.diameter_m; const roleLabel = (role: "inlet" | "outlet" | "extra") => role === "inlet" ? "유입" : role === "outlet" ? "유출" : "추가(성토부)"; // ④ 유출구측 성토부선(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로). // 구간별 폴리라인: 관 하단 꼭짓점(또는 앞 추가 벽 전면 상단) → 다음 벽/원지반. const planLine = (points: (typeof pipe.inlet)[], tooltip: string): void => { if (points.length < 2) return; const line = document.createElementNS(SVG_NS, "polyline"); line.setAttribute( "points", points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), ); // 성토·절토 접속선은 공사 계획선 — 설계선과 같은 보라 실선. line.setAttribute("class", "b06-chart__design-cross"); const title = document.createElementNS(SVG_NS, "title"); title.textContent = tooltip; line.append(title); layer.append(line); }; const drawOutletFill = (): void => { for (const segment of layout.outletFill.segments) { if (segment.kind === "cut") { // 매몰 구간 — 0도 절토선을 원지반 교차까지(2026-08-22 사용자 ③). planLine( segment.points, `유출부 3도 절토선(계류 쪽 내림 — 물이 밖으로) — 벽이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`, ); continue; } planLine( segment.points, `유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + (segment.overLimit ? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)" : " (법정 5m 이내)") + ` · 성토 물매 1:${(segment.ratio ?? layout.fillSlope.ratio).toFixed(2)}` + (layout.fillSlope.roadWideningM > 0.01 ? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m` : "") + (layout.fillSlope.structureRequired ? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)" : ""), ); } // 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②). if (layout.inletFill) { planLine( layout.inletFill.points, layout.inletFill.kind === "cut" ? `유입부 접속 절토선 — 관 하단에서 3도(계류 쪽 오름) 1m 후 표준 절토 경사로 원지반까지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)` : `유입부 3도 성토선(계류 쪽 오름 — 물이 관으로) — 원지반 교차에서 정지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`, ); } }; // ①·② 구조물 — 유출측(추가 벽 포함)이 먼저 서고, 그 다음 유입측. const wallsInOrder = [ ...layout.walls.filter((w) => w.role === "outlet"), ...layout.extraWalls, ...layout.walls.filter((w) => w.role === "inlet"), ]; const keyOf = (wall: (typeof wallsInOrder)[number]): RevetKey => wall.role === "extra" ? (`extra${wall.extraIndex ?? 0}` as RevetKey) : wall.role; const revetShapes = new Map(); /** 집수정 부재 도형 — 유입 선택 강조에 쓴다(2026-08-22 사용자 ③). */ const basinShapes: SVGPolygonElement[] = []; for (const wall of wallsInOrder) { // 합성 단면(하부 사다리꼴 + 상부 평행사변형) — 상단 배면이 사면선 접점(사용자 ①·②). const revetShape = polygon( wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), "b06-chart__culvert-revet", `${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${wall.height.toFixed(1)}m` + `(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` + `, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` + (wall.floatGapM > 0.01 ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)` : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)`) + (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""), ); if (onSelectRevet) revetShape.classList.add("is-selectable"); revetShapes.set(keyOf(wall), revetShape); layer.append(revetShape); // 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선): // 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다. const joint = document.createElementNS(SVG_NS, "line"); // 하단선이 원지반을 따라 기울어 있으므로 이음선 바닥도 하단선 위에서 잡는다. const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M; const bottomSpan = wall.bottomFront.offset - wall.bottomBack.offset; const bottomAtOffset = (offset: number): number => Math.abs(bottomSpan) > 1e-9 ? wall.bottomBack.elevation + (wall.bottomFront.elevation - wall.bottomBack.elevation) * ((offset - wall.bottomBack.offset) / bottomSpan) : wall.bottomBack.elevation; joint.setAttribute("x1", String(x(wall.topJoint.offset))); joint.setAttribute("y1", String(toDisplayY(wall.topJoint.elevation))); joint.setAttribute("x2", String(x(jointBaseOffset))); joint.setAttribute("y2", String(toDisplayY(bottomAtOffset(jointBaseOffset)))); joint.setAttribute("class", "b06-chart__culvert-stone"); layer.append(joint); // 돌 해칭 — 큰 돌이 한 줄로 쌓인 **계류측 기운 띠**(전면 평행사변형)를 따라 쌓는다. // 좌·우 벽은 outward 부호로 자동 반전된다. 벽 높이는 지형마다 달라지므로 // ① 배치 구간을 근입 바닥~상단 **전체**로 잡고 ② 벽 폴리곤 clip 안에만 그려 // 어떤 높이에서도 돌이 벽 밖으로 새지 않게 한다(2026-08-20 미스매치 정정). const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; const clipId = `b06-revet-clip-${keyOf(wall)}-${Math.round(wall.backOffset * 100)}`; const clip = document.createElementNS(SVG_NS, "clipPath"); clip.setAttribute("id", clipId); clip.append( polygon( wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), "", "", ), ); const stoneGroup = document.createElementNS(SVG_NS, "g"); stoneGroup.setAttribute("clip-path", `url(#${clipId})`); layer.append(clip, stoneGroup); const centerBaseOffset = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2); const embedBase = bottomAtOffset(centerBaseOffset); const stoneSpan = Math.max(0.3, wall.topBack.elevation - embedBase); const stoneCount = Math.max(2, Math.round(stoneSpan / 0.45)); const stoneHeight = stoneSpan / stoneCount; // 돌 중심선 = 전면 기운 띠(이음선~전면)의 중심선. 바닥은 근입 바닥까지 연장한다. const centerBase = centerBaseOffset; const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2); // 화면 좌표 기준 기움 각(rect의 세로축을 경사축에 맞춘다). const axisX = x(centerTop) - x(centerBase); const axisY = toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase); const leanDegrees = (Math.atan2(axisX, -axisY) * 180) / Math.PI; for (let i = 0; i < stoneCount; i += 1) { const fraction = (i + 0.5) / stoneCount; const centerOffset = centerBase + (centerTop - centerBase) * fraction; const centerElevation = embedBase + stoneSpan * fraction; const cx = x(centerOffset); const cy = toDisplayY(centerElevation); const stone = document.createElementNS(SVG_NS, "rect"); const widthPx = REVET_THICKNESS_M * pixelsPerMeter * 0.9; const heightPx = stoneHeight * pixelsPerMeter * 0.86; stone.setAttribute("x", String(cx - widthPx / 2)); stone.setAttribute("y", String(cy - heightPx / 2)); stone.setAttribute("width", String(widthPx)); stone.setAttribute("height", String(heightPx)); stone.setAttribute("rx", String(Math.min(widthPx, heightPx) * 0.3)); stone.setAttribute("transform", `rotate(${leanDegrees.toFixed(1)} ${cx} ${cy})`); stone.setAttribute("class", "b06-chart__culvert-stone"); stoneGroup.append(stone); } } if (layout.basin) { const basin = layout.basin; const shapeName = ((shape: BasinShape) => shape === "L" ? "ㄴ형" : shape === "U" ? "ㄷ형" : "I형")(basin.shape); const reasonText = basin.reason === "cut" ? "절토측 유입 — 집수정 기본(성토사면 없음)" : basin.reason === "manual" ? "사용자 선택 집수정(규칙상 기슭막이 자리)" : "유입측 성토사면 3m 이하 — 기슭막이 대신 집수정 기본"; for (const part of basin.parts) { const shape = polygon( part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), "b06-chart__culvert-basin", `유입 집수정(${shapeName}) ${part.kind === "floor" ? "바닥" : "벽"}` + ` — ${reasonText} · 내공 1.0m(실무 돌집수정) · 부재 두께 0.2m`, ); basinShapes.push(shape); layer.append(shape); } if (basin.cutLine) { const cut = document.createElementNS(SVG_NS, "line"); cut.setAttribute("x1", String(x(basin.cutLine.from.offset))); cut.setAttribute("y1", String(toDisplayY(basin.cutLine.from.elevation))); cut.setAttribute("x2", String(x(basin.cutLine.to.offset))); cut.setAttribute("y2", String(toDisplayY(basin.cutLine.to.elevation))); // 절토선은 **공사 계획선**이다 — 설계선과 같은 보라 실선으로 그린다 // (2026-08-20 사용자 ②). cut.setAttribute("class", "b06-chart__design-cross"); const cutTitle = document.createElementNS(SVG_NS, "title"); cutTitle.textContent = `집수정(${shapeName}) 설치 절토선(계획선) — 구조물이 원지반 안쪽에 들어가 절토 필요`; cut.append(cutTitle); layer.append(cut); } if (basin.fillLine) { // 구조물이 원지반 밖으로 나온 경우의 성토(되메움) 계획선 — 경사 5도(임시값, // 2026-08-22 사용자 지정). const fill = document.createElementNS(SVG_NS, "line"); fill.setAttribute("x1", String(x(basin.fillLine.from.offset))); fill.setAttribute("y1", String(toDisplayY(basin.fillLine.from.elevation))); fill.setAttribute("x2", String(x(basin.fillLine.to.offset))); fill.setAttribute("y2", String(toDisplayY(basin.fillLine.to.elevation))); fill.setAttribute("class", "b06-chart__design-cross"); const fillTitle = document.createElementNS(SVG_NS, "title"); fillTitle.textContent = basin.shape === "I" ? "집수정(I형) 되메움(성토)선 — 관 하단에서 3도(계류 쪽 오름)로 원지반과 연결" : `집수정(${shapeName}) 성토(되메움)선 — 구조물이 원지반 밖으로 나와 5° 경사로 채움`; fill.append(fillTitle); layer.append(fill); } const label = document.createElementNS(SVG_NS, "text"); label.setAttribute("x", String(x(basin.label.offset))); // 라벨은 I형 벽 **하단** 바깥에 붙인다(2026-08-20 사용자) — 글자 높이만큼 내린다. label.setAttribute("y", String(toDisplayY(basin.label.elevation) + 11)); label.setAttribute("text-anchor", "middle"); label.setAttribute("class", "b06-chart__culvert-label"); label.textContent = `집수정(${shapeName})`; layer.append(label); } // ③ 관 — 축 법선 두께의 직사각형. 끝단면은 구조물 계류측 변에서 잘려 온다(Geom). const { axis, normal } = layout.pipeAxis; // 미세한 틈 방지: 양 끝을 축 방향으로 아주 조금만 밀어 넣는다. 벽 전면이 1:0.3으로 // 기울어 있어 크게 밀면 관 하단이 벽 **바깥으로 삐져나온다**(2026-08-20 사용자 ③ // 위치 어긋남 원인) — 렌더링 틈만 덮을 만큼(2㎝)으로 줄였다. const OVERLAP_M = 0.02; const pushOut = (corner: PipeEnd, dirSign: number): PipeEnd => ({ bottom: { offset: corner.bottom.offset + dirSign * axis.offset * OVERLAP_M, elevation: corner.bottom.elevation + dirSign * axis.elevation * OVERLAP_M, }, top: { offset: corner.top.offset + dirSign * axis.offset * OVERLAP_M, elevation: corner.top.elevation + dirSign * axis.elevation * OVERLAP_M, }, }); const inletFinal = pushOut(pipeCorners.inlet, -1); const outletFinal = pushOut(pipeCorners.outlet, 1); const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : ""; const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter); const tip = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipe.lengthM}.0m` + ` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` + ` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` + ` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`; // 외경(관 두께 바깥) → 내경 순으로 2겹. 도면처럼 관벽 두께가 보이게 한다(사용자 ③). const shifted = (corner: PipeEnd, delta: number): PipeEnd => ({ bottom: { offset: corner.bottom.offset - normal.offset * delta, elevation: corner.bottom.elevation - normal.elevation * delta, }, top: { offset: corner.top.offset + normal.offset * delta, elevation: corner.top.elevation + normal.elevation * delta, }, }); for (const [delta, cls] of [ [wallThickness, "b06-chart__culvert-pipe b06-chart__culvert-pipe--outer"], [0, "b06-chart__culvert-pipe"], ] as const) { const a = shifted(inletFinal, delta); const b = shifted(outletFinal, delta); layer.append( polygon( [ [x(a.bottom.offset), toDisplayY(a.bottom.elevation)], [x(b.bottom.offset), toDisplayY(b.bottom.elevation)], [x(b.top.offset), toDisplayY(b.top.elevation)], [x(a.top.offset), toDisplayY(a.top.elevation)], ], cls, tip, ), ); } drawOutletFill(); // 클릭 판정용 투명 겹면 — **맨 마지막에** 얹는다. 관·보호공이 벽 위를 지나가 그리기 // 순서상 벽 가운데를 덮으므로, 벽 폴리곤에 직접 핸들러를 달면 가운데를 눌러도 관이 // 먼저 먹는다(2026-08-21 화면 확인). 그리기 순서(벽 → 관 → 보호공)는 그대로 두고 // 판정면만 위로 올린다. 관 자체의 툴팁은 벽 밖 구간에서 그대로 뜬다. if (onSelectRevet) { for (const wall of wallsInOrder) { const hit = polygon( wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), "b06-chart__culvert-revet-hit", `${roleLabel(wall.role)} 기슭막이 선택 — 카드 하단 ◀/▶로 자리 조절`, ); hit.addEventListener("click", (event) => { event.stopPropagation(); onSelectRevet(keyOf(wall)); }); 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); } } } // 강조는 클래스만 갈아 끼운다 — 카드를 다시 그리면 휠 줌·팬이 초기화된다. return (key) => { for (const [role, shape] of revetShapes) shape.classList.toggle("is-active", role === key); // 유입이 집수정이면 부재 전체를 함께 강조한다(2026-08-22 사용자 ③). for (const shape of basinShapes) shape.classList.toggle("is-active", key === "inlet"); }; }