From 6f76fe9f318c86df75c60c40cf723aa23a8abc14 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:01:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EB=85=B8=EB=A9=B4=EA=B3=B5=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EB=B3=84=20=ED=8F=AC=EC=9E=A5=20=EC=96=B9?= =?UTF-8?q?=EA=B8=B0=20=EC=A7=9D=20section.pavement=20=E2=80=94=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=C2=B7=20=EC=B4=88=EC=95=88=20=C2=B7=20?= =?UTF-8?q?=EA=B1=B0=EC=9A=B8=20=EC=8B=9C=ED=97=98(88-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- B06_Section/B06_Section_Api_Types.ts | 3 + B06_Section/B06_Section_Router.py | 3 + B06_Section/B06_Section_Section_Store.ts | 14 ++ common_util/common_util_structure_pavement.py | 116 ++++++++++++++++ common_util/common_util_structure_pavement.ts | 111 +++++++++++++++ .../test_b06_structure_pavement_mirror.py | 131 ++++++++++++++++++ 6 files changed, 378 insertions(+) create mode 100644 common_util/common_util_structure_pavement.py create mode 100644 common_util/common_util_structure_pavement.ts create mode 100644 resources/tester/test_b06_structure_pavement_mirror.py diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index ae320539e..967019212 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -60,6 +60,7 @@ export type { /** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */ 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"; export type { FordPavementSpec, RevetmentSpec }; export interface SectionContextResponse { @@ -315,6 +316,8 @@ export interface CrossSection extends SectionStation { ford_pavement?: FordPavementSpec; /** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */ revetment?: RevetmentSpec; + /** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */ + pavement?: PavementEntry[]; } export interface SectionDetailResponse { diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 5190e78c2..a1963de5a 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -77,6 +77,7 @@ from B06_Section.B06_Section_Schema import ( from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for from common_util.common_util_auth import verify_session from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_structure_pavement import attach_pavement_structures from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import get_workflow_state from config.config_db import get_db_pool, run_with_connection @@ -274,6 +275,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic # 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 — # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). attach_wall_structures(root, cross_sections) + attach_pavement_structures(root, cross_sections) # 노면공 형상(88) return {"longitudinal": longitudinal, "cross_sections": cross_sections} @@ -497,6 +499,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"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 2eb75a1db..85fd95747 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -34,6 +34,11 @@ import { wallSpecsFrom, type WallStructureInput, } from "@util/common_util_structure_walls"; +import { + attachPavementSpecs, + pavementSpecsFrom, + type PavementStructureInput, +} from "@util/common_util_structure_pavement"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; @@ -65,6 +70,15 @@ async function withDraftWalls( await withDraftPipeKinds(detail, projectId); if (!drafts || !drafts.length) return withStructureAreas(detail); 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)); + if (paveTypes.size) { + for (const section of detail.cross_sections) delete section.pavement; + attachPavementSpecs( + detail.cross_sections as unknown as Array>, + pavementSpecsFrom(drafts as unknown as PavementStructureInput[], paveTypes), + ); + } const names = new Map( types // 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 diff --git a/common_util/common_util_structure_pavement.py b/common_util/common_util_structure_pavement.py new file mode 100644 index 000000000..26d12b7d6 --- /dev/null +++ b/common_util/common_util_structure_pavement.py @@ -0,0 +1,116 @@ +"""노면공(G군) 구조물 정본 → 횡단 측점 포장 제원(`section.pavement`). + +범위(start~end · ±0.02 m) 안 측점마다 `[{type_id, spec, thickness_m, start_m, end_m, widening_on}]`. +두께 = `options.height_m` · 무늬 = `options.pave_spec` · 확폭 끈 구간 = `options.widening_off`("s-e,s-e"). +그림 · 정보만 — 토공 면적 · 포장 켜기(`design.paved`)는 건드리지 않음. + +**짝**: `common_util/common_util_structure_pavement.ts` — 브라우저는 저장 전 목록으로 같은 제원을 얹음. +거울 시험 `resources/tester/test_b06_structure_pavement_mirror.py`. +""" + +import logging +import math +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — C군 벽(`common_util_structure_walls`)과 같은 값. +_EDGE_TOLERANCE_M = 0.02 + + +def _num(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return value if math.isfinite(value) else None + + +def widening_off_ranges(text: Any) -> list[tuple[float, float]]: + """「시작-끝,시작-끝」(측점 m) → 구간 목록. 못 읽는 조각은 버림(= 켬).""" + ranges: list[tuple[float, float]] = [] + for part in str(text or "").split(","): + bounds = part.split("-") + if len(bounds) != 2 or not bounds[0].strip() or not bounds[1].strip(): + continue + try: + start, end = float(bounds[0]), float(bounds[1]) + except ValueError: + continue + if math.isfinite(start) and math.isfinite(end): + ranges.append((min(start, end), max(start, end))) + return ranges + + +def pavement_specs_from( + structures: list[dict[str, Any]], type_ids: set[str] +) -> list[dict[str, Any]]: + """G군 구조물 → 구간 제원. 범위가 없으면(시작 · 끝 · 기준 모두 빔) 건너뜀.""" + specs: list[dict[str, Any]] = [] + for item in structures: + if item.get("type_id") not in type_ids: + continue + anchor = _num(item.get("chainage_m")) + start = _num(item.get("start_m")) + end = _num(item.get("end_m")) + start = anchor if start is None else start + end = anchor if end is None else end + if start is None or end is None: + continue + options = item.get("options") or {} + spec = options.get("pave_spec") + specs.append( + { + "type_id": item["type_id"], + "spec": spec if isinstance(spec, str) and spec else None, + "thickness_m": _num(options.get("height_m")), + "start_m": min(start, end), + "end_m": max(start, end), + "widening_off": widening_off_ranges(options.get("widening_off")), + } + ) + return specs + + +def _inside(chainage: float, start: float, end: float) -> bool: + return start - _EDGE_TOLERANCE_M <= chainage <= end + _EDGE_TOLERANCE_M + + +def attach_pavement_specs(sections: list[dict[str, Any]], specs: list[dict[str, Any]]) -> int: + """범위 안 측점에 `pavement` 목록을 얹음. 얹은 측점 수를 돌려줌.""" + attached = 0 + for section in sections: + chainage = _num(section.get("chainage_m")) + if chainage is None: + continue + found = [ + { + "type_id": spec["type_id"], + "spec": spec["spec"], + "thickness_m": spec["thickness_m"], + "start_m": spec["start_m"], + "end_m": spec["end_m"], + "widening_on": not any( + _inside(chainage, start, end) for start, end in spec["widening_off"] + ), + } + for spec in specs + if _inside(chainage, spec["start_m"], spec["end_m"]) + ] + if found: + section["pavement"] = found + attached += 1 + return attached + + +def attach_pavement_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: + """저장된 구조물 정본으로 `section.pavement` 를 얹음. 정본을 못 읽으면 0(비치명).""" + from B05_Profile.B05_Profile_Structures_Repository import load_structures + from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + + try: + type_ids = {key for key, value in structure_type_map().items() if value.group == "G"} + structures = [item.model_dump() for item in load_structures(str(project_root))[1]] + except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 감 + logger.exception("B06 노면공 정본을 읽지 못했습니다 (없는 것으로 본다)") + return 0 + return attach_pavement_specs(cross_sections, pavement_specs_from(structures, type_ids)) diff --git a/common_util/common_util_structure_pavement.ts b/common_util/common_util_structure_pavement.ts new file mode 100644 index 000000000..e4a6a0539 --- /dev/null +++ b/common_util/common_util_structure_pavement.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * common_util_structure_pavement.ts + * 노면공(G군) 구조물 → 횡단 측점 포장 제원(`section.pavement`). + * 파이썬 `common_util/common_util_structure_pavement.py` 의 **짝** — 거울 시험 + * `resources/tester/test_b06_structure_pavement_mirror.py`. + * + * 서버는 저장분으로 · 브라우저는 저장 전 목록으로 같은 제원을 얹음. + * 두께 = options.height_m · 무늬 = options.pave_spec · 확폭 끈 구간 = options.widening_off. + * ========================================================================== */ + +/** 구조물 정본 한 건(필요한 칸만) — `structures.json` 과 같은 이름. */ +export interface PavementStructureInput { + type_id: string; + chainage_m?: number | null; + start_m?: number | null; + end_m?: number | null; + options?: Record | null; +} + +/** 측점에 얹는 포장 한 건 — `section.pavement[]`. */ +export interface PavementEntry { + type_id: string; + /** 포장사양 — 콘크리트 · 아스콘 · 쇄석(비면 표준 그림). */ + spec: string | null; + /** 두께(m) — 노면선 아래로. 비면 표준 횡단 포장 두께. */ + thickness_m: number | null; + start_m: number; + end_m: number; + /** false = 확폭 끈 구간(규격 폭 끝까지만). */ + widening_on: boolean; +} + +export interface PavementSpec extends Omit { + widening_off: Array<[number, number]>; +} + +/** 파이썬 `_EDGE_TOLERANCE_M` — C군 벽과 같은 값. */ +const EDGE_TOLERANCE_M = 0.02; + +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 「시작-끝,시작-끝」(측점 m) → 구간 목록. 못 읽는 조각은 버림(= 켬). */ +export function wideningOffRanges(text: unknown): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + for (const part of String(text ?? "").split(",")) { + const bounds = part.split("-"); + if (bounds.length !== 2 || !bounds[0].trim() || !bounds[1].trim()) continue; + const start = Number(bounds[0]); + const end = Number(bounds[1]); + if (Number.isFinite(start) && Number.isFinite(end)) + ranges.push([Math.min(start, end), Math.max(start, end)]); + } + return ranges; +} + +/** G군 구조물 → 구간 제원(파이썬 `pavement_specs_from`). */ +export function pavementSpecsFrom( + structures: readonly PavementStructureInput[], + typeIds: ReadonlySet, +): PavementSpec[] { + const specs: PavementSpec[] = []; + for (const item of structures) { + if (!typeIds.has(item.type_id)) continue; + const anchor = num(item.chainage_m); + const start = num(item.start_m) ?? anchor; + const end = num(item.end_m) ?? anchor; + if (start === null || end === null) continue; + const options = (item.options ?? {}) as Record; + const spec = options.pave_spec; + specs.push({ + type_id: item.type_id, + spec: typeof spec === "string" && spec ? spec : null, + thickness_m: num(options.height_m), + start_m: Math.min(start, end), + end_m: Math.max(start, end), + widening_off: wideningOffRanges(options.widening_off), + }); + } + return specs; +} + +const inside = (chainage: number, start: number, end: number): boolean => + start - EDGE_TOLERANCE_M <= chainage && chainage <= end + EDGE_TOLERANCE_M; + +/** 범위 안 측점에 `pavement` 목록을 얹음(파이썬 `attach_pavement_specs`). 얹은 측점 수. */ +export function attachPavementSpecs( + sections: Array>, + specs: readonly PavementSpec[], +): number { + let attached = 0; + for (const section of sections) { + const chainage = num(section.chainage_m); + if (chainage === null) continue; + const found: PavementEntry[] = specs + .filter((spec) => inside(chainage, spec.start_m, spec.end_m)) + .map((spec) => ({ + type_id: spec.type_id, + spec: spec.spec, + thickness_m: spec.thickness_m, + start_m: spec.start_m, + end_m: spec.end_m, + widening_on: !spec.widening_off.some(([start, end]) => inside(chainage, start, end)), + })); + if (found.length) { + section.pavement = found; + attached += 1; + } + } + return attached; +} diff --git a/resources/tester/test_b06_structure_pavement_mirror.py b/resources/tester/test_b06_structure_pavement_mirror.py new file mode 100644 index 000000000..ad33bded7 --- /dev/null +++ b/resources/tester/test_b06_structure_pavement_mirror.py @@ -0,0 +1,131 @@ +"""노면공 포장 제원(88) — 파이썬·TS 짝 거울 시험. + +`common_util/common_util_structure_pavement.py`(서버 · 저장분) 와 `.ts`(브라우저 · 초안)가 +같은 입력에 같은 `section.pavement` 를 내는지 대조. TS 는 프로젝트 tsc 로 옮겨 실제 코드 그대로 돌림. +""" + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +from common_util.common_util_structure_pavement import ( # noqa: E402 + attach_pavement_specs, + pavement_specs_from, +) + +STRUCTURES = [ + { + "type_id": "pavement_concrete", + "placement": "interval", + "chainage_m": None, + "start_m": 100.0, + "end_m": 160.0, + "options": {"pave_spec": "콘크리트", "height_m": 0.25, "widening_off": "120-140, x-1,,"}, + }, + { + # 뒤집힌 구간 · 두께 빔 · 사양 빔 — 두 벌 다 작은 값을 시작으로 · null 로. + "type_id": "gravel_surfacing", + "placement": "interval", + "start_m": 150.0, + "end_m": 130.0, + "options": {"pave_spec": "", "height_m": "0.3"}, + }, + {"type_id": "pavement_concrete", "start_m": None, "end_m": None, "options": {}}, + {"type_id": "masonry_wet", "start_m": 0.0, "end_m": 500.0, "options": {}}, + { + "type_id": "pavement_concrete", + "chainage_m": 300.0, + "options": {"pave_spec": "아스콘", "height_m": 0.05, "widening_off": "290-310"}, + }, +] +TYPE_IDS = ["pavement_concrete", "gravel_surfacing"] +CHAINAGES = [99.97, 100.0, 119.99, 130.0, 145.0, 160.02, 160.05, 300.0, 400.0] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { attachPavementSpecs, pavementSpecsFrom } from "./common_util_structure_pavement.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const sections = input.chainages.map((chainage_m) => ({ chainage_m })); +attachPavementSpecs(sections, pavementSpecsFrom(input.structures, new Set(input.type_ids))); +writeFileSync(process.argv[3], JSON.stringify(sections)); +""" + + +def _python_result() -> list[dict]: + sections = [{"chainage_m": value} for value in CHAINAGES] + attach_pavement_specs(sections, pavement_specs_from(STRUCTURES, set(TYPE_IDS))) + return sections + + +def _ts_result(tmp_path: Path) -> list[dict]: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(PROJECT_ROOT / "common_util" / "common_util_structure_pavement.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + payload = tmp_path / "input.json" + result = tmp_path / "output.json" + payload.write_text( + json.dumps({"structures": STRUCTURES, "type_ids": TYPE_IDS, "chainages": CHAINAGES}), + encoding="utf-8", + ) + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(payload), str(result)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_pavement_specs_mirror(): + with tempfile.TemporaryDirectory() as workdir: + ts = _ts_result(Path(workdir)) + py = _python_result() + assert ts == py + by = {section["chainage_m"]: section.get("pavement") for section in py} + # 범위 밖 · 경계 오차(0.02 m) 안 + assert by[99.97] is None and by[160.05] is None and by[400.0] is None + assert [entry["type_id"] for entry in by[100.0]] == ["pavement_concrete"] + assert by[160.02][0]["thickness_m"] == 0.25 + # 확폭 끈 구간 120~140 (±0.02) — 끔 · 못 읽는 조각은 버림 + assert by[119.99][0]["widening_on"] is False + assert by[100.0][0]["widening_on"] is True + # 겹친 범위 = 둘 다 · 뒤집힌 구간 정렬 · 문자 두께 · 빈 사양 = null + assert [entry["type_id"] for entry in by[145.0]] == ["pavement_concrete", "gravel_surfacing"] + gravel = by[145.0][1] + assert (gravel["start_m"], gravel["end_m"]) == (130.0, 150.0) + assert gravel["thickness_m"] is None and gravel["spec"] is None + # 기준 측점만 있는 옛 항목 = 그 측점 한 점 + assert by[300.0] == [ + { + "type_id": "pavement_concrete", + "spec": "아스콘", + "thickness_m": 0.05, + "start_m": 300.0, + "end_m": 300.0, + "widening_on": False, + } + ]