diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 45a42aa34..d74e1ce27 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -61,6 +61,7 @@ export type { import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement"; import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment"; import type { PavementEntry } from "@util/common_util_structure_pavement"; +import type { UnderdrainSpec } from "@util/common_util_structure_underdrain"; export type { FordPavementSpec, RevetmentSpec }; export interface SectionContextResponse { @@ -336,6 +337,8 @@ export interface CrossSection extends SectionStation { revetment?: RevetmentSpec; /** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */ pavement?: PavementEntry[]; + /** 맹암거 — 기준 측점(288-1). 높이 · 경사 · 측구 없음 · 노면 기울임 · 돌 무늬 형상. */ + underdrain?: UnderdrainSpec; } export interface SectionDetailResponse { diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index d4fd143f2..95b11ad4d 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -360,8 +360,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), paved: pavedBySpecs(section), // 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원) - tilted: Boolean(section.ford_pavement), - tiltPct: section.ford_pavement?.slope_pct ?? null, + tilted: Boolean(section.ford_pavement || section.underdrain), + tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null, standard, berm: bermAt(section.chainage_m, design), rockBoundaryOffsetM, diff --git a/B06_Section/B06_Section_Engine_Tilt.py b/B06_Section/B06_Section_Engine_Tilt.py new file mode 100644 index 000000000..3fa30b969 --- /dev/null +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -0,0 +1,100 @@ +"""구조물 자리 기울기 — 물넘이포장 · 맹암거 측점의 노면(287-4 · 288-1). + +그 자리 횡단 설계 = 측구 없음 · 노면 = 한 경사로 지대 낮은 쪽(`compute_cross_design(tilted=…, +tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 암거 경사(%) · 비면 표준 횡단경사. + · 물넘이포장 = 관 지점 정본(pipe_points) 범위(기준 ± 월류 폭/2). + · 맹암거 = 구조물 정본(structures.json) A군 점형 · 기준 측점 한 곳 — 횡단 dict 에 + `underdrain`(높이 · 경사)을 얹어 횡단도가 돌 무늬 형상을 그림(TS 짝 + `common_util_structure_underdrain.ts`). +판정 폭 = 포장 범위와 같은 0.02 m(`paved_at`). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets + +logger = logging.getLogger(__name__) + +UNDERDRAIN_TYPE_ID = "cross_underdrain" +_EDGE_TOLERANCE_M = 0.02 + +TiltRange = tuple[float, float, float | None] + + +def _number(value: Any) -> float | None: + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if parsed == parsed else None + + +def load_underdrains(project_root: Path) -> list[dict[str, Any]]: + """구조물 정본의 맹암거 — `{structure_id, chainage_m, height_m, slope_pct}` · + 실패하면 빈 목록.""" + try: + structures = load_structures(str(project_root))[1] + except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 + logger.exception("B06 맹암거를 읽지 못했습니다 (없음으로 본다)") + return [] + found: list[dict[str, Any]] = [] + for structure in structures: + if structure.type_id != UNDERDRAIN_TYPE_ID: + continue + options = structure.options or {} + height = _number(options.get("culvert_height_m")) + slope = _number(options.get("culvert_slope_pct")) + found.append( + { + "structure_id": structure.structure_id, + "chainage_m": float(structure.anchor_m()), + "height_m": height if height and height > 0 else None, + "slope_pct": slope if slope and slope > 0 else None, + } + ) + return found + + +def tilt_ranges(project_root: Path | None) -> list[TiltRange]: + """측구 없이 노면을 지대 낮은 쪽 한 경사로 기울이는 자리 — (시작, 끝, 경사 %).""" + if project_root is None: + return [] + out: list[TiltRange] = [] + for at, spec in load_culvert_sets(project_root).items(): + if spec.get("type") == "ford_pavement": + half = float(spec.get("span_m") or 0.0) / 2.0 + out.append((at - half, at + half, spec.get("slope_pct"))) + for drain in load_underdrains(project_root): + out.append((drain["chainage_m"], drain["chainage_m"], drain["slope_pct"])) + return out + + +def tilt_args(chainage_m: float, ranges: list[TiltRange]) -> dict[str, Any]: + """그 측점의 구조물 자리 기울기 — `compute_cross_design(**...)` 로 넘김 · + 저장분 짝 `stored_tilt`.""" + tol = _EDGE_TOLERANCE_M + hit = next((r for r in ranges if r[0] - tol <= chainage_m <= r[1] + tol), None) + pct = hit[2] if hit else None + return {"tilted": hit is not None, "tilt_pct": float(pct) if pct is not None else None} + + +def attach_underdrains(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: + """맹암거 기준 측점의 횡단 dict 에 `underdrain` 을 얹는다. 얹은 개수를 돌려준다.""" + drains = load_underdrains(project_root) + attached = 0 + for section in cross_sections: + chainage = _number(section.get("chainage_m")) + if chainage is None: + continue + hit = next( + (d for d in drains if abs(d["chainage_m"] - chainage) <= _EDGE_TOLERANCE_M), None + ) + if hit: + section["underdrain"] = dict(hit) + attached += 1 + return attached diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index d640c495b..7c9256058 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -26,6 +26,7 @@ from B06_Section.B06_Section_Engine_Design import ( stored_shoulder_extend, ) from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures +from B06_Section.B06_Section_Engine_Tilt import attach_underdrains, tilt_args, tilt_ranges from B06_Section.B06_Section_Repository import ( count_cross_sections, create_longitudinal_section, @@ -51,8 +52,6 @@ from B06_Section.B06_Section_Router_Design import ( pavement_ranges, stored_berm, stored_cut_slope, - tilt_args, - tilt_ranges, ) from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, @@ -287,6 +286,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). attach_wall_structures(root, cross_sections) attach_pavement_structures(root, cross_sections) # 노면공 형상(88) + attach_underdrains(root, cross_sections) # 맹암거 형상(288-1) return {"longitudinal": longitudinal, "cross_sections": cross_sections} @@ -450,6 +450,7 @@ async def regenerate_sections( attach_culvert_sets(project_root, result["cross_sections"]) attach_wall_structures(project_root, result["cross_sections"]) attach_pavement_structures(project_root, result["cross_sections"]) + attach_underdrains(project_root, result["cross_sections"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 878399892..035ec26ff 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -15,6 +15,7 @@ from B06_Section.B06_Section_Engine_Design import ( stored_shoulder_extend, stored_tilt, ) +from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges from common_util.common_util_cross_berm import ( BERM_DEFAULT_DEPTH_M, BERM_DEFAULT_INTERVAL_M, @@ -94,27 +95,6 @@ def ford_pavement_ranges(project_root: Path | None) -> list[tuple[float, float]] return [(at - half, at + half) for at, half in halves] -def tilt_ranges(project_root: Path | None) -> list[tuple[float, float, float | None]]: - """측구 없이 노면을 지대 낮은 쪽 한 경사로 기울이는 자리(287-4) — 물넘이포장 범위 · - 경사 = 경사각도(%).""" - if project_root is None: - return [] - out: list[tuple[float, float, float | None]] = [] - for at, spec in load_culvert_sets(project_root).items(): - if spec.get("type") == "ford_pavement": - half = float(spec.get("span_m") or 0.0) / 2.0 - out.append((at - half, at + half, spec.get("slope_pct"))) - return out - - -def tilt_args(chainage_m: float, ranges: list[tuple[float, float, float | None]]) -> dict[str, Any]: - """그 측점의 구조물 자리 기울기 — `compute_cross_design(**...)` 로 넘김 · - 저장분 짝 `stored_tilt`.""" - hit = next((r for r in ranges if paved_at(chainage_m, [(r[0], r[1])])), None) - pct = hit[2] if hit else None - return {"tilted": hit is not None, "tilt_pct": float(pct) if pct is not None else None} - - def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]: """세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)]. diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 67465f56d..38b9ddbf6 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -40,6 +40,11 @@ import { type PavementStructureInput, } from "@util/common_util_structure_pavement"; import { setGeneralStations } from "../common_util/common_util_structure_station"; +import { + attachUnderdrainSpecs, + underdrainSpecsFrom, + type UnderdrainInput, +} from "../common_util/common_util_structure_underdrain"; import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; @@ -74,6 +79,11 @@ async function withDraftWalls( // 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인). await withDraftPipeKinds(detail, projectId); if (!drafts || !drafts.length) return withStructureAreas(detail); + // 맹암거(288-1) — 초안 기준 측점으로 제원을 다시 얹음(짝 `common_util_structure_underdrain`) + attachUnderdrainSpecs( + detail.cross_sections as unknown as Array>, + underdrainSpecsFrom(drafts as unknown as UnderdrainInput[]), + ); const types = await fetchStructureTypes().catch(() => []); // 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`) const paveTypes = new Set(types.filter((type) => type.group === "G").map((type) => type.type_id)); diff --git a/B06_Section/B06_Section_UI_Cross_Underdrain.ts b/B06_Section/B06_Section_UI_Cross_Underdrain.ts new file mode 100644 index 000000000..e3bba0f0e --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Underdrain.ts @@ -0,0 +1,136 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Underdrain.ts + * 맹암거 횡단 형상(288-1 · 요구 183-3). + * + * · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음. + * · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도. + * · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남. + * · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(흙막이 돌 그리기 `drawStones`). + * 높이가 비면 그리지 않음(수치를 지어내지 않음). + * ========================================================================== */ + +import type { CrossDesign } from "./B06_Section_Api_Fetch"; +import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { drawStones } from "./B06_Section_UI_Cross_Wall_Hatch"; +import type { UnderdrainSpec } from "@util/common_util_structure_underdrain"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +let clipSeq = 0; + +type Edge = { offset_m: number; elevation_m: number }; + +/** 한쪽 둘레(노면 끝 → 옵셋선과 만나는 자리) — `outward` = 바깥 부호(+1 좌 · −1 우). */ +function sidePath( + edge: Edge, + outward: 1 | -1, + line: readonly Edge[], + roadGrade: number, + heightM: number, +): OffsetPoint[] { + const start = { offset: edge.offset_m, elevation: edge.elevation_m }; + const offZ = (o: number) => edge.elevation_m + roadGrade * (o - edge.offset_m) - heightM; + const outside = line + .filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6) + .sort((a, b) => (a.offset_m - b.offset_m) * outward); + const first = outside[0]; + if (!first) return [start, { offset: start.offset, elevation: offZ(start.offset) }]; + if (first.elevation_m >= edge.elevation_m) { + // 절토 — 사면을 안쪽 아래로 늘여 옵셋선과 만남 + const k = (first.elevation_m - edge.elevation_m) / (first.offset_m - edge.offset_m); + const d = Math.abs(k - roadGrade) < 1e-9 ? 0 : -heightM / (k - roadGrade); + const offset = edge.offset_m + d; + return [start, { offset, elevation: offZ(offset) }]; + } + // 성토 — 사면을 따라 내려가다 옵셋선을 만나는 자리 + const path: OffsetPoint[] = [start]; + let prev = edge; + for (const point of outside) { + const fa = prev.elevation_m - offZ(prev.offset_m); + const fb = point.elevation_m - offZ(point.offset_m); + if (fb <= 0) { + const t = fa / (fa - fb); + const offset = prev.offset_m + (point.offset_m - prev.offset_m) * t; + path.push({ offset, elevation: offZ(offset) }); + return path; + } + path.push({ offset: point.offset_m, elevation: point.elevation_m }); + prev = point; + } + path.push({ offset: prev.offset_m, elevation: offZ(prev.offset_m) }); + return path; +} + +/** 맹암거 형상 둘레 — 좌 노면 끝 → 우 노면 끝 → 우 둘레 → 옵셋선 → 좌 둘레(위로). 높이 없으면 null. */ +export function underdrainOutline( + design: CrossDesign, + heightM: number | null, +): OffsetPoint[] | null { + const edges = design.road_edges; + if (!edges || !(heightM && heightM > 0)) return null; + const { left, right } = edges; + const span = left.offset_m - right.offset_m; + if (!(Math.abs(span) > 1e-9)) return null; + const grade = (left.elevation_m - right.elevation_m) / span; + const line = design.design_line ?? []; + const leftPath = sidePath(left, 1, line, grade, heightM); + const rightPath = sidePath(right, -1, line, grade, heightM); + return [ + { offset: left.offset_m, elevation: left.elevation_m }, + ...rightPath, + ...leftPath.slice(1).reverse(), + ]; +} + +/** 맹암거 형상을 그린다(돌 무늬 · 둘레 클립). 그렸으면 그 무리 — 둘레에 고름 표식(`data-structure-id` · 288-2). */ +export function appendUnderdrainOverlay( + svg: SVGElement, + spec: UnderdrainSpec | undefined, + design: CrossDesign, + x: (offset: number) => number, + y: (elevation: number) => number, +): SVGGElement | null { + const outline = spec ? underdrainOutline(design, spec.height_m) : null; + if (!spec || !outline) return null; + const px = (p: OffsetPoint): [number, number] => [x(p.offset), y(p.elevation)]; + const points = outline.map((p) => px(p).join(",")).join(" "); + const group = document.createElementNS(SVG_NS, "g") as SVGGElement; + group.setAttribute("class", "b06-chart__underdrain"); + const body = document.createElementNS(SVG_NS, "polygon"); + body.setAttribute("points", points); + body.setAttribute("class", "b06-chart__underdrain-body"); + clipSeq += 1; + const clipId = `b06-underdrain-clip-${clipSeq}`; + const clip = document.createElementNS(SVG_NS, "clipPath"); + clip.setAttribute("id", clipId); + const clipShape = document.createElementNS(SVG_NS, "polygon"); + clipShape.setAttribute("points", points); + clip.append(clipShape); + const stones = document.createElementNS(SVG_NS, "g"); + stones.setAttribute("clip-path", `url(#${clipId})`); + const height = spec.height_m!; + const offsets = outline.map((p) => p.offset); + const [lo, hi] = [Math.min(...offsets), Math.max(...offsets)]; + const { left, right } = design.road_edges; + const grade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m); + const roadZ = (o: number) => left.elevation_m + grade * (o - left.offset_m); + const at = (u: number, v: number): OffsetPoint => { + const offset = lo + (hi - lo) * u; + return { offset, elevation: roadZ(offset) - height * (v + 0.5) }; + }; + const [ax, ay] = px(at(0, 0)); + const [bx, by] = px(at(1, 0)); + drawStones( + stones, + { + spanM: Math.hypot(hi - lo, grade * (hi - lo)), + pixelsPerMeter: Math.abs(x(1) - x(0)) || 1, + point: (u, v) => px(at(u, v)), + leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI, + }, + false, + height, + ); + group.append(body, clip, stones); + svg.append(group); + return group; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index e8178a2a2..d21aac8a6 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -21,6 +21,7 @@ import { } from "./B06_Section_UI_Cross_Ford_Pavement"; import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire"; import { attachShapePick } from "./B06_Section_UI_Cross_Shape_Pick"; +import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch"; import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill"; @@ -454,6 +455,8 @@ export function createCrossSectionCard( ); if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement); + // 맹암거(288-1) — 노면 아래 암거 높이만큼 돌 무늬 형상 + appendUnderdrainOverlay(plotLayer, section.underdrain, section.design, x, toDisplayY); // 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면" // 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자). appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY, fordLayout); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index b970a0463..d875d8a78 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -267,6 +267,18 @@ stroke-width: 1; } +/* 맹암거(288-1): 노면 아래 돌 무늬 형상 */ +.b06-chart__underdrain-body { + fill: color-mix(in srgb, #3aa0a8 16%, transparent); + stroke: #3aa0a8; + stroke-width: 1.2; +} + +.b06-chart__underdrain-body.is-active { + fill: color-mix(in srgb, #3aa0a8 34%, transparent); + stroke-width: 2.2; +} + /* 배수관 세트(2026-08-19): 배관·기슭막이·돌붙임 — 분리 도형 3종 + 집수정 라벨 */ /* 관은 실무 도면처럼 점선 윤곽 2줄 느낌 — 채움은 식별용으로만 아주 옅게. */ .b06-chart__culvert-pipe { diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index 81a1400e1..6af40280b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -17,6 +17,7 @@ import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box"; import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford"; import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; +import { appendUnderdrainOverlay } from "../B06_Section/B06_Section_UI_Cross_Underdrain"; import { appendCrossDesignOverlay, appendPavementOverlay, @@ -487,6 +488,7 @@ function drawDesign( const { design, culvert, box, ford, own } = layouts; const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y); if (!paved) appendPavementOverlay(svg, design, x, y, section.pavement); + appendUnderdrainOverlay(svg, section.underdrain, design, x, y); // 맹암거(288-1) appendCrossDesignOverlay( svg, design, diff --git a/common_util/common_util_structure_underdrain.ts b/common_util/common_util_structure_underdrain.ts new file mode 100644 index 000000000..ca2a91d44 --- /dev/null +++ b/common_util/common_util_structure_underdrain.ts @@ -0,0 +1,66 @@ +/* ============================================================================= + * common_util/common_util_structure_underdrain.ts + * 맹암거(cross_underdrain · A군 점형) 제원을 횡단 측점에 얹는다(288-1). + * + * 기준 측점(0.02 m 안) 횡단 dict 에 `underdrain` = 높이 · 경사 — 횡단도가 돌 무늬 형상을 그리고, + * 설계 계산은 그 측점을 측구 없이 지대 낮은 쪽 한 경사로 기울임(`tilted`). + * 파이썬 짝 = `B06_Section/B06_Section_Engine_Tilt.py`(`load_underdrains` · `attach_underdrains`). + * ========================================================================== */ + +export const UNDERDRAIN_TYPE_ID = "cross_underdrain"; +const EDGE_TOLERANCE_M = 0.02; + +/** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 없으면 표준 횡단경사). */ +export interface UnderdrainSpec { + structure_id: string | null; + chainage_m: number; + height_m: number | null; + slope_pct: number | null; +} + +export interface UnderdrainInput { + structure_id?: string | null; + type_id: string; + chainage_m?: number | null; + start_m?: number | null; + options?: Record | null; +} + +const positive = (value: unknown): number | null => { + const parsed = value === "" || value === null || value === undefined ? NaN : Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +}; + +/** 구조물 목록의 맹암거 제원(파이썬 `load_underdrains` 짝). */ +export function underdrainSpecsFrom(structures: readonly UnderdrainInput[]): UnderdrainSpec[] { + return structures.flatMap((structure) => { + const at = structure.chainage_m ?? structure.start_m; + if (structure.type_id !== UNDERDRAIN_TYPE_ID || typeof at !== "number") return []; + const options = structure.options ?? {}; + return [ + { + structure_id: structure.structure_id ?? null, + chainage_m: at, + height_m: positive(options.culvert_height_m), + slope_pct: positive(options.culvert_slope_pct), + }, + ]; + }); +} + +/** 기준 측점 횡단 dict 에 `underdrain` 을 얹는다(옛 값은 걷음) — 얹은 개수(파이썬 `attach_underdrains` 짝). */ +export function attachUnderdrainSpecs( + sections: Array>, + specs: readonly UnderdrainSpec[], +): number { + let attached = 0; + for (const section of sections) { + delete section.underdrain; + const chainage = Number(section.chainage_m); + const hit = specs.find((spec) => Math.abs(spec.chainage_m - chainage) <= EDGE_TOLERANCE_M); + if (!hit) continue; + section.underdrain = { ...hit }; + attached += 1; + } + return attached; +} diff --git a/resources/tester/helper_288_underdrain.cjs b/resources/tester/helper_288_underdrain.cjs new file mode 100644 index 000000000..5611079cc --- /dev/null +++ b/resources/tester/helper_288_underdrain.cjs @@ -0,0 +1,87 @@ +/* 288 맹암거 횡단 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로. + * 입력 = argv[2] JSON 파일 { structures, sections }. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +class Node { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + append(...kids) { + this.children.push(...kids); + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const util = load(path.join(ROOT, "common_util", "common_util_structure_underdrain.ts")); +const draw = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Underdrain.ts")); + +const specs = util.underdrainSpecsFrom(input.structures); +const sections = input.sections.map((s) => ({ ...s })); +const attached = util.attachUnderdrainSpecs(sections, specs); + +// 지대 낮은 쪽(우)으로 5 % 기울인 노면 · 좌 = 절토(오름) · 우 = 성토(내림) · 암거 높이 1 m +const design = { + road_edges: { + left: { offset_m: 3, elevation_m: 100.15 }, + right: { offset_m: -3, elevation_m: 99.85 }, + }, + design_line: [ + { offset_m: 6, elevation_m: 102.9 }, + { offset_m: 3, elevation_m: 100.15 }, + { offset_m: -3, elevation_m: 99.85 }, + { offset_m: -7, elevation_m: 97.2 }, + ], +}; +const svg = new Node("g"); +const group = draw.appendUnderdrainOverlay( + svg, + { structure_id: "ud-1", chainage_m: 40, height_m: 1, slope_pct: 5 }, + design, + (o) => o, + (e) => e, +); +const body = group?.children.find((k) => k.attrs.class === "b06-chart__underdrain-body"); + +process.stdout.write( + JSON.stringify({ + specs, + attached, + sections: sections.map((s) => s.underdrain ?? null), + outline: draw.underdrainOutline(design, 1), + noHeight: draw.underdrainOutline(design, null), + drew: !!group, + bodyId: body?.attrs["data-structure-id"] ?? null, + stones: group?.children.filter((k) => k.tag === "g").flatMap((g) => g.children).length ?? 0, + }), +); diff --git a/resources/tester/test_286_1_ford_pavement_design.py b/resources/tester/test_286_1_ford_pavement_design.py index 68c52322a..da373f8af 100644 --- a/resources/tester/test_286_1_ford_pavement_design.py +++ b/resources/tester/test_286_1_ford_pavement_design.py @@ -138,7 +138,7 @@ def test_저장_뒤_서버가_물넘이_측구를_맞춤(): router = _read("B06_Section", "B06_Section_Router_Design.py") assert "stored_tilt(design) == tilt" in router refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") - assert "tilted: Boolean(section.ford_pavement)," in refresh + assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh CHUTE = {"ford_chute_1_structure": "돌붙임", "ford_chute_1_thickness": 30} diff --git a/resources/tester/test_288_underdrain_cross.py b/resources/tester/test_288_underdrain_cross.py new file mode 100644 index 000000000..b78011f18 --- /dev/null +++ b/resources/tester/test_288_underdrain_cross.py @@ -0,0 +1,143 @@ +"""288 맹암거 횡단 형상(요구 183-3 · 9). + +못박는 것 + - 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 암거 경사로 지대 낮은 쪽 한 경사(물넘이 286 · 287 길 — + 서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted`) · 제원 얹기 서버 `attach_underdrains` ↔ TS + `attachUnderdrainSpecs` 같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과 + 만남 · 절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림. + +진짜 TS 는 `helper_288_underdrain.cjs` 가 Node 로 돌림. 화면은 사용자 확인. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B06_Section.B06_Section_Engine_Tilt as tilt # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 + +STRUCTURES = [ + { + "structure_id": "ud-1", + "type_id": "cross_underdrain", + "placement": "point", + "chainage_m": 40.0, + "options": {"culvert_height_m": 0.8, "culvert_slope_pct": 4}, + }, + { # 경사 · 높이 빈 맹암거 — 표준 횡단경사 · 형상 없음 + "structure_id": "ud-2", + "type_id": "cross_underdrain", + "placement": "point", + "chainage_m": 80.0, + "options": {}, + }, + { + "structure_id": "x-1", + "type_id": "cross_drain_exposed", + "placement": "point", + "chainage_m": 60.0, + }, +] +SECTIONS = [{"chainage_m": c} for c in (20.0, 40.0, 60.0, 80.0)] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture() +def project(monkeypatch, tmp_path) -> Path: + items = [StructureInstance(**s) for s in STRUCTURES] + monkeypatch.setattr(tilt, "load_structures", lambda _root: (1, items)) + monkeypatch.setattr(tilt, "load_culvert_sets", lambda _root: {}) + return tmp_path + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s288") / "in.json" + source.write_text( + json.dumps({"structures": STRUCTURES, "sections": SECTIONS}), encoding="utf-8" + ) + proc = subprocess.run( + ["node", str(HERE / "helper_288_underdrain.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_맹암거_자리_기울기_서버(project): + ranges = tilt.tilt_ranges(project) + assert ranges == [(40.0, 40.0, 4.0), (80.0, 80.0, None)] + assert tilt.tilt_args(40.0, ranges) == {"tilted": True, "tilt_pct": 4.0} + assert tilt.tilt_args(80.01, ranges) == {"tilted": True, "tilt_pct": None} + assert tilt.tilt_args(60.0, ranges) == {"tilted": False, "tilt_pct": None} + + +def test_제원_얹기_서버_TS_같은_값(project, ts): + sections = [dict(s) for s in SECTIONS] + assert tilt.attach_underdrains(project, sections) == 2 == ts["attached"] + assert [s.get("underdrain") for s in sections] == ts["sections"] + assert ts["sections"][1] == { + "structure_id": "ud-1", + "chainage_m": 40.0, + "height_m": 0.8, + "slope_pct": 4.0, + } + + +def test_맹암거_측점_설계_측구_없음_낮은_쪽_기울기(): + samples = [ + {"offset_m": o / 2, "elevation_m": round(100.0 + 0.35 * o / 2, 4), "valid": True} + for o in range(-24, 25) + ] + kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"} + got = compute_cross_design(samples, 100.4, tilted=True, tilt_pct=4.0, **kwargs) + assert got["ditch_enabled"] is False and got["cross_slope_pct"] == 4.0 + left, right = (got["road_edges"][k]["elevation_m"] for k in ("left", "right")) + assert left > right # 지반이 좌로 오름 = 우가 낮음 + + +def test_형상_옵셋선_성토_절토와_만남(ts): + outline = [(p["offset"], p["elevation"]) for p in ts["outline"]] + expected = [ + (3, 100.15), # 좌 노면 끝 + (-3, 99.85), # 우 노면 끝 + (-4.632653, 98.768367), # 우 성토 사면 ∩ 옵셋선 + (1.846154, 99.092308), # 좌 절토 사면을 늘인 선 ∩ 옵셋선 + ] + assert len(outline) == len(expected) + for got, want in zip(outline, expected): + assert got == pytest.approx(want, abs=1e-5) + assert ts["noHeight"] is None + assert ts["drew"] is True and ts["stones"] > 0 + + +def test_횡단도_B07_이_맹암거를_그림(): + view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") + assert ( + "appendUnderdrainOverlay(plotLayer, section.underdrain, section.design, x, toDisplayY);" + in view + ) + b07 = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_Cross_Overlay.ts").read_text( + encoding="utf-8" + ) + assert "appendUnderdrainOverlay(svg, section.underdrain, design, x, y);" in b07 + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh