Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-12 21:17:41 +09:00
17 changed files with 623 additions and 31 deletions
+3
View File
@@ -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 {
+2 -2
View File
@@ -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,
+100
View File
@@ -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
+3 -2
View File
@@ -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"]
)
+1 -21
View File
@@ -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)].
+10
View File
@@ -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<Record<string, unknown>>,
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));
@@ -11,6 +11,8 @@
* ========================================================================== */
const SVG_NS = "http://www.w3.org/2000/svg";
/** 관 지점이 아닌 구조물(맹암거) 형상 누름 — 카드 밖(구조물 목록)이 id 로 고름(288-2). */
export const SHAPE_PICK_EVENT = "b06-structure-shape-pick";
/** 끌기로 볼 움직임(px) — 이보다 크면 고름 아님. */
export const SHAPE_DRAG_PX = 3;
@@ -49,6 +51,15 @@ export function attachShapePick(
return (active) => shapes.forEach((shape) => shape.classList.toggle("is-active", active));
}
/** 형상 누름 창구(287-5 · 288-2) — 관 지점(`pipe-…` · 물넘이포장) = 그 카드 시설 고름 · 그 밖(맹암거) = id 를
* 실은 알림을 위로 띄움(구조물 목록이 받음). */
export function shapePickRoute(from: Element, pickPipe: () => void): (structureId: string) => void {
return (structureId) => {
if (structureId.startsWith("pipe-")) return pickPipe();
from.dispatchEvent(new CustomEvent(SHAPE_PICK_EVENT, { bubbles: true, detail: structureId }));
};
}
/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴). */
export function highlightShapes(root: ParentNode, structureId: string | null): void {
for (const shape of root.querySelectorAll<SVGElement>("[data-structure-id]")) {
@@ -0,0 +1,137 @@
/* =============================================================================
* 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");
if (spec.structure_id) body.setAttribute("data-structure-id", spec.structure_id);
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;
}
+8 -2
View File
@@ -20,7 +20,8 @@ import {
appendFordSurfaceDropPlan,
} 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 { attachShapePick, shapePickRoute } 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);
@@ -576,7 +579,10 @@ export function createCrossSectionCard(
setChuteActive(highlightRevet === "chute");
}
// 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2) — 다른 시설과 같은 창구 · 보라 손잡이 아래 · 누름만
const setShapeActive = attachShapePick(plotLayer, revetOffset && (() => toggleRevet("pipe")));
const setShapeActive = attachShapePick(
plotLayer,
revetOffset && shapePickRoute(svg, () => toggleRevet("pipe")),
);
if (setShapeActive) {
const baseSetActive = setRevetActive;
setRevetActive = (key) => {
+6 -1
View File
@@ -69,7 +69,7 @@ import {
import type { SectionViewController } from "./B06_Section_UI_Section_View_Types";
import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice";
import { highlightPavement, pavementAt } from "./B06_Section_UI_Cross_Design_Surface";
import { highlightShapes } from "./B06_Section_UI_Cross_Shape_Pick";
import { highlightShapes, SHAPE_PICK_EVENT } from "./B06_Section_UI_Cross_Shape_Pick";
import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile";
import type { DesignPageShared } from "../A00_Common/design/design_shared";
@@ -440,6 +440,11 @@ export function createSectionView(
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다.
grid.style.gridTemplateColumns = `repeat(${columnCount}, ${cachedCardWidth}px)`;
grid.dataset.columns = String(columnCount); // 4열부터 면적표 「계」만(173-3 · CSS)
// 맹암거 형상 누름 = 그 구조물 id 고름(288-2 · 노면공 포장 범위 표식과 따로 · 같은 id 고름 창구)
grid.addEventListener(SHAPE_PICK_EVENT, (event) => {
const id = (event as CustomEvent<string>).detail;
if (id) pavementSelectListener?.(id);
});
cachedRowHeight.clear();
if (detail.cross_sections.length) {
// 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로.
@@ -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 {
@@ -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,
@@ -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<string, unknown> | 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<Record<string, unknown>>,
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;
}
+104
View File
@@ -0,0 +1,104 @@
/* 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");
// 288-2 형상 누름 창구 — 물넘이(pipe-…) = 시설 고름 · 맹암거 = id 알림(목록이 고름)
globalThis.CustomEvent = class {
constructor(type, init) {
this.type = type;
this.detail = init?.detail;
this.bubbles = init?.bubbles;
}
};
const shapePick = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts"));
const routed = [];
const from = { dispatchEvent: (event) => routed.push([event.type, event.detail, event.bubbles]) };
const route = shapePick.shapePickRoute(from, () => routed.push(["pipe"]));
route("pipe-230.00");
route("ud-1");
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,
routed,
eventName: shapePick.SHAPE_PICK_EVENT,
stones: group?.children.filter((k) => k.tag === "g").flatMap((g) => g.children).length ?? 0,
}),
);
@@ -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}
@@ -121,8 +121,8 @@ def test_형상_강조와_표식(ts):
def test_보라_손잡이는_계획고만_형상_누름은_고름만():
view = _read("B06_Section", "B06_Section_UI_Cross_View.ts")
# 형상 누름 = 시설 고름 창구만(설계 · 계획고를 안 건드림) · 보라 선 고름보다 먼저 꽂음(손잡이가 위)
assert 'attachShapePick(plotLayer, revetOffset && (() => toggleRevet("pipe")))' in view
assert view.index("attachShapePick(plotLayer") < view.index("attachDesignLinePick({")
assert 'shapePickRoute(svg, () => toggleRevet("pipe"))' in view
assert view.index("attachShapePick(") < view.index("attachDesignLinePick({")
pick = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts")
assert "onDesignChange" not in pick and "design_elevation" not in pick
assert '"b06-chart__culvert-revet-hit"' in pick # 구조물 판정면 앞(아래)에 꽂을 기준만
@@ -0,0 +1,155 @@
"""288 맹암거 횡단 형상(요구 183-3 · 9).
못박는 것
- 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 암거 경사로 지대 낮은 쪽 한 경사(물넘이 286 · 287 길 —
서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted`) · 제원 얹기 서버 `attach_underdrains` ↔ TS
`attachUnderdrainSpecs` 같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과
만남 · 절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림.
- 288-2 맹암거 형상 누름 = 그 구조물 id 고름(구조물 목록 창구 · 알약 · 폼 · 측점선 따라옴) · 물넘이와 같은
판정면(보라 손잡이 아래 · 끌기 = 고름 아님 · 287-5) · 노면공 포장 범위 고름과 따로.
진짜 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
def test_맹암거_형상_누름은_그_구조물_고름(ts):
assert ts["bodyId"] == "ud-1" # 둘레 = 형상 고름 표식
assert ts["routed"] == [["pipe"], [ts["eventName"], "ud-1", True]]
view = (ROOT / "B06_Section" / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8")
assert "grid.addEventListener(SHAPE_PICK_EVENT, (event) => {" in view
assert "if (id) pavementSelectListener?.(id);" in view # = structuresPanel.selectStructure(id)
cross = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
assert 'revetOffset && shapePickRoute(svg, () => toggleRevet("pipe")),' in cross