/* ============================================================================= * B06_Section_UI_Cross_Ford_Pavement.ts * 물넘이포장 — 횡단도 그림(287-2 · 287-4 · 그림_183). * * · 콘크리트 윗면 = 설계 노면 — 서버(`compute_cross_design(tilted=…)`) · 짝 TS 가 노면을 경사각도 * (`ford_slope_deg` → `slope_pct`)로 지대 낮은 쪽 한 경사로 기울여 낸 면 · 설계 중심점이 그 위. * 노면 밖은 설계선 그대로 — 노면 끝에서 성토 · 절토 사면이 이어짐(늘어나지 못한 자리 = 성토). * · 포장층 = 노면공 포장 형상(`appendPavementOverlay`)을 노견까지. * · 월류 높이선(회색 점선) = 노면 위로 월류 높이(`depth_m`)만큼 · 노면과 같은 각도. * · 도수로 · 돌붙임 띠 = 유출(지대 낮은 쪽) 성토사면. * ========================================================================== */ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface"; import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; /** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */ export interface FordPavementSpec { /** 관 지점 누가거리 — 폭 절반 걸친 옆 카드도 같은 값. */ chainage_m?: number; span_m: number; /** 월류 높이(m) — 노면 위 점선. 없으면 점선을 안 그림(수치를 지어내지 않는다). */ depth_m: number | null; /** 노면 경사(%) — 경사각도 환산 · 서버 설계 노면이 이 값으로 기욺. */ slope_pct: number | null; /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ defaulted?: string[]; /** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠 · 길이 = 사면 따라(286-3 · 비면 원지반까지). */ chute?: { structure: string; thickness_m: number; length_m?: number }; } /** 유입(상류)이 좌측(+offset)인가 — 기울인 노면이면 높은 노면 끝 · 아니면 설계선 양 끝 표고가 높은 * 쪽(286-2) · 같으면 단면유형. */ export function inflowLeft(design: CrossDesign): boolean { const edges = design.road_edges; if (design.tilted && edges && Math.abs(edges.left.elevation_m - edges.right.elevation_m) > 1e-6) return edges.left.elevation_m > edges.right.elevation_m; const line = [...(design.design_line ?? [])].sort((a, b) => a.offset_m - b.offset_m); const gap = line.length >= 2 ? line[line.length - 1].elevation_m - line[0].elevation_m : 0; return Math.abs(gap) > 1e-3 ? gap > 0 : design.section_mode !== "right_cut"; } function line(points: string[], className: string): SVGPolylineElement { const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("points", points.join(" ")); polyline.setAttribute("class", className); return polyline; } /** * 세월교로 **내려 앉힌 노면** 위에 "월류가 없었다면" 노면을 점선으로 되그린다 * (2026-08-30 사용자 확정). 계획고는 이미 백엔드가 월류 높이만큼 내려 잡았으므로 * (`design.surface_drop_m`) 여기서는 그 양만큼 위로 올린 선을 얹는다. * 물넘이포장의 「기존 계획고 점선」과 같은 표기라 사용자가 한 규칙으로 읽는다. * * 양 끝은 **측벽 상단의 계류측 꼭짓점까지 대각으로 내려 잇는다**(2026-08-30 사용자: * 좌측 벽이면 좌상단, 우측 벽이면 우상단, 각도는 무시). 월류 전 노면이 구체 밖에서 * 어디로 떨어지는지가 그 선으로 읽힌다. */ export function appendFordSurfaceDropPlan( svg: SVGElement, design: CrossDesign, x: (offset: number) => number, y: (elevation: number) => number, ford?: { sides: ReadonlyArray<{ outward: number; wallTopOuter: OffsetPoint }>; ford?: { overflow_depth_m?: number }; } | null, ): void { // 점선 = 노면 + 월류 높이(66-3) — 패널 월류 높이(세트 제원)가 먼저 · 저장 뒤 내린 양과 같은 값 const drop = ford?.ford?.overflow_depth_m ?? design.surface_drop_m ?? 0; if (!(drop > 0) || !design.road_edges) return; const { left, right } = design.road_edges; // +offset이 좌측이다 — 좌측 벽 상단 → 좌 노견 → 우 노견 → 우측 벽 상단 순. const cornerAt = (side: 1 | -1): OffsetPoint | undefined => ford?.sides.find((entry) => Math.sign(entry.outward) === side)?.wallTopOuter; const points: OffsetPoint[] = [ { offset: left.offset_m, elevation: left.elevation_m + drop }, { offset: right.offset_m, elevation: right.elevation_m + drop }, ]; const leftCorner = cornerAt(1); if (leftCorner) points.unshift(leftCorner); const rightCorner = cornerAt(-1); if (rightCorner) points.push(rightCorner); svg.append( line( points.map((point) => `${x(point.offset)},${y(point.elevation)}`), "b06-chart__ford-deck-plan", ), ); } /** * 물넘이포장을 그린다(287-2 · 287-4) — 포장층(노면공 형상 · 노견까지) · 월류 높이선 · 도수로 띠. * 그렸으면 true — 호출부는 일반 포장층을 건너뜀(두 겹으로 안 깔림). 포장층에 물넘이 표식 * (`b06-chart__ford-pavement` · 287-5 고름이 노면공 포장 고름과 가름). */ export function appendFordPavementOverlay( svg: SVGElement, spec: FordPavementSpec | undefined, design: CrossDesign, x: (offset: number) => number, y: (elevation: number) => number, ): boolean { const edges = design.road_edges; if (!spec || !edges) return false; const from = svg.children.length; // 노면공 항목은 안 넘김 — 물넘이 자체 포장(노면공 포장 고름과 안 섞임) appendPavementOverlay(svg, { ...design, paved: true, carriageway_edges: edges }, x, y, null); [...svg.children] .slice(from) .filter((el) => el.getAttribute("class") === "b06-chart__pavement") .forEach((el) => { el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement"); // 형상 고름 표식(287-5) — 관 지점 알약 id 와 같은 글 if (typeof spec.chainage_m === "number") el.setAttribute("data-structure-id", `pipe-${spec.chainage_m.toFixed(2)}`); }); // 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) const depth = spec.depth_m ?? 0; if (depth > 0) svg.append( line( [ `${x(edges.left.offset_m)},${y(edges.left.elevation_m + depth)}`, `${x(edges.right.offset_m)},${y(edges.right.elevation_m + depth)}`, ], "b06-chart__ford-deck-plan", ), ); if (spec.chute) appendChuteBand(svg, [{ points: fordChuteLine(design, spec.chute) }], spec.chute, x, y); return true; } /** * 유출측(`inflowLeft` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라 * 내려가는 동안만(오르면 절토라 멈춤). 성토가 없으면 빈 선(띠 안 그림). */ export function outletFillLine(design: CrossDesign): OffsetPoint[] { const edges = design.road_edges; if (!edges || !design.design_line?.length) return []; const outward = inflowLeft(design) ? -1 : 1; // +offset = 좌측 · 유입 반대 const edge = outward > 0 ? Math.max(edges.left.offset_m, edges.right.offset_m) : Math.min(edges.left.offset_m, edges.right.offset_m); const points = design.design_line .filter((p) => (p.offset_m - edge) * outward >= -1e-6) .sort((a, b) => (a.offset_m - b.offset_m) * outward); const line: OffsetPoint[] = []; for (const p of points) { const last = line[line.length - 1]; if (last && p.elevation_m > last.elevation + 1e-6) break; line.push({ offset: p.offset_m, elevation: p.elevation_m }); } return line; } /** * 물넘이 도수로 · 돌붙임 선(286-3) — 유출 성토사면 선을 노견에서 사면 따라 `length_m` 까지 자름 · * 비거나 사면보다 길면 원지반까지. 횡단 띠 · 3D 띠가 같이 씀(그림 전용). */ export function fordChuteLine(design: CrossDesign, chute?: { length_m?: number }): OffsetPoint[] { const line = outletFillLine(design); let left = chute?.length_m ?? 0; if (!(left > 0)) return line; const out = line.slice(0, 1); for (let i = 1; i < line.length && left > 1e-9; i += 1) { const [a, b] = [line[i - 1], line[i]]; const step = Math.hypot(b.offset - a.offset, b.elevation - a.elevation); if (step < 1e-9) continue; const t = Math.min(left / step, 1); out.push({ offset: a.offset + (b.offset - a.offset) * t, elevation: a.elevation + (b.elevation - a.elevation) * t, }); left -= step; } return out; } /** * 좌측 패널의 물넘이 칸을 캐시 제원에 얹는다(66-2 · 66-3) — 월류 높이 = 파임 깊이 · 경사각(°) → %. * 서버 `_ford_pavement_set` 과 같은 환산(저장 뒤 서버가 정본으로 다시 냄). 바꿨으면 true. */ export function applyFordPavementOptions( spec: FordPavementSpec, patch: Record, ): boolean { const num = (key: string): number | undefined => { const value = patch[key] === "" ? NaN : Number(patch[key]); return Number.isFinite(value) ? value : undefined; }; const height = num("ford_height_m"); const degree = num("ford_slope_deg"); if (height !== undefined) spec.depth_m = height > 0 ? height : null; if (degree !== undefined) { spec.slope_pct = Number((Math.tan((degree * Math.PI) / 180) * 100).toFixed(4)); spec.defaulted = spec.defaulted?.filter((item) => !item.startsWith("경사각 ")); } return height !== undefined || degree !== undefined; }