diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 43540c9f2..67d055929 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -662,24 +662,32 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]] chainage = _number(section.get("chainage_m"), None) if chainage is None: continue - for pipe_chainage, spec in sets.items(): + # ⚠ 키가 다른 시설은 같은 측점에 함께 얹는다(299-1) — 첫 시설 하나에서 멈추면 물넘이포장 + # 연동 범위(폭 절반) 안 측점의 **자기 관**이 빠졌다(95 m 배관이 90 m 물넘이에 가려짐). + # 같은 키끼리는 소유 측점 것 → 흙막이 아닌 자기 시설 → 가까운 것 → 관 지점 차례. + best: dict[str, tuple[tuple[Any, ...], dict[str, Any]]] = {} + for order, (pipe_chainage, spec) in enumerate(sets.items()): # 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다(`_SPAN_LINKED_TYPES`). reach = _CHAINAGE_TOLERANCE_M if spec.get("type") in _SPAN_LINKED_TYPES: reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2 - if abs(chainage - pipe_chainage) <= reach or owner.get(pipe_chainage) == chainage: - # 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. - kind = spec.get("type") - # ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의 - # 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려 - # **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리). - section[_SECTION_KEYS.get(str(kind), "culvert")] = { - **spec, - "chainage_m": pipe_chainage, - } - # 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음. - if spec.get("guard"): - section["culvert"] = {**spec["guard"], "chainage_m": pipe_chainage} - attached += 1 - break + owned = owner.get(pipe_chainage) == chainage + if not (abs(chainage - pipe_chainage) <= reach or owned): + continue + # 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. + entries = [(_SECTION_KEYS.get(str(spec.get("type")), "culvert"), spec, False)] + # 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음 · + # 자기 관과 부딪히면 관 먼저(흙막이가 덮으면 관 그림 · 연장이 빠짐). + if spec.get("guard"): + entries.append(("culvert", spec["guard"], True)) + for key, value, is_guard in entries: + rank = (not owned, is_guard, abs(chainage - pipe_chainage), order) + if key not in best or rank < best[key][0]: + # ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의 + # 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려 + # **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리). + best[key] = (rank, {**value, "chainage_m": pipe_chainage}) + for key, (_, value) in best.items(): + section[key] = value + attached += bool(best) return attached diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index b58a26deb..ba1c749a7 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -580,6 +580,12 @@ export function buildCulvertSets( return sets; } +/** 차례 비교(파이썬 튜플 `<` 와 같음) — 처음 다른 자리에서 작은 쪽이 먼저. */ +function rankBefore(a: readonly number[], b: readonly number[]): boolean { + const at = a.findIndex((value, index) => value !== b[index]); + return at >= 0 && a[at] < b[at]; +} + /** 측점 자료에 세트를 얹는다(파이썬 `attach_culvert_sets`). 얹은 개수를 돌려준다. */ export function attachCulvertSets( sections: Array>, @@ -607,25 +613,35 @@ export function attachCulvertSets( for (const section of sections) { const chainage = num(section.chainage_m, null); if (chainage === null) continue; + // ⚠ 키가 다른 시설은 같은 측점에 함께 얹는다(299-1 · 짝: 파이썬 `attach_culvert_sets`) — 첫 시설 + // 하나에서 멈추면 물넘이포장 연동 범위 안 측점의 **자기 관**이 빠졌다. 같은 키끼리는 소유 측점 + // 것 → 흙막이 아닌 자기 시설 → 가까운 것 → 관 지점 차례. + const best = new Map(); + let order = 0; for (const [pipeChainage, spec] of sets) { + order += 1; // 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다. let reach = CHAINAGE_TOLERANCE_M; if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2; - if (Math.abs(chainage - pipeChainage) <= reach || owner.get(pipeChainage) === chainage) { - // ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬 - // `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면 - // 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**. - section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = { - ...spec, - chainage_m: pipeChainage, - }; - // 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음. - if (spec.guard) - section.culvert = { ...(spec.guard as CulvertSetSpec), chainage_m: pipeChainage }; - attached += 1; - break; + const owned = owner.get(pipeChainage) === chainage; + if (!(Math.abs(chainage - pipeChainage) <= reach || owned)) continue; + const entries: Array<[string, CulvertSetSpec, number]> = [ + [SECTION_KEYS[String(spec.type)] ?? "culvert", spec, 0], + ]; + // 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`) · 자기 관과 부딪히면 관 먼저. + if (spec.guard) entries.push(["culvert", spec.guard as CulvertSetSpec, 1]); + for (const [key, value, isGuard] of entries) { + const rank = [owned ? 0 : 1, isGuard, Math.abs(chainage - pipeChainage), order]; + const held = best.get(key)?.rank; + // ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의 절반까지 + // 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 + // 측점에서 센다**. + if (!held || rankBefore(rank, held)) + best.set(key, { rank, value: { ...value, chainage_m: pipeChainage } }); } } + for (const [key, { value }] of best) section[key] = value; + if (best.size) attached += 1; } return attached; } diff --git a/resources/tester/test_299_ford_span_pipe.py b/resources/tester/test_299_ford_span_pipe.py new file mode 100644 index 000000000..d5de86e2a --- /dev/null +++ b/resources/tester/test_299_ford_span_pipe.py @@ -0,0 +1,145 @@ +"""299-1 물넘이 연동 범위 안 측점의 자기 관 — 같은 측점에 함께 얹힘 · 그림 · 수량 한 번. + +까닭 — `attach_culvert_sets` 가 측점마다 첫 맞는 시설 하나만 얹고 멈춰, 검증 프로젝트 95 m 배관 +(관경 800 · 도수로)이 90 m 물넘이포장(폭 10 → 85~95 m 연동)에 가려 횡단도 · 관 연장에서 빠졌다. + +못박는 것 + - 키가 다른 시설(배관 culvert · 세월교 ford · 박스 box · 물넘이 ford_pavement)은 같은 측점에 함께. + - 같은 키는 소유 측점 것이 옆 측점 연동보다 먼저 · 세월교 · 박스 흙막이(guard)는 자기 관과 + 부딪히면 관 먼저(흙막이 없는 자리는 그대로 흙막이). + - 수량 = 소유 측점 한 번 — 소비처(B06 `Structure_Layouts` · B08)가 `chainage_m` 의 가장 가까운 + 측점만 세므로 시설마다 주인 한 곳 · 관 연장은 주인 측점의 `culvert`(흙막이 `body` 아님)만. + - 파이썬 ↔ TS 짝(`common_util_culvert_sets.ts`) 같은 값. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +for path in (ROOT, Path(__file__).resolve().parent): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import test_b06_culvert_sets_mirror as mirror # noqa: E402 +from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets, pipe_points_file # noqa: E402 + +# 검증 프로젝트 꼴 — 20 · 90 물넘이(폭 10) · 95 배관(도수로) · 100 배관 + 100.3 박스(유출 흙막이) · 60 세월교(흙막이) +POINTS = [ + {"chainage_m": 20.0, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}}, + {"chainage_m": 90.0, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}}, + { + "chainage_m": 95.0, + "facility": "pipe", + "options": {"pipe_diameter_mm": 800, "chute_add_1_structure": "도수로"}, + }, + { + "chainage_m": 60.0, + "facility": "ford_bridge", + "options": { + "ford_width_m": 6.0, + "inlet_type": "흙막이", + "inlet_revet_form": "콘크리트", + "inlet_revet_height_m": 1.5, + }, + }, + {"chainage_m": 100.0, "facility": "pipe", "options": {"pipe_diameter_mm": 600}}, + { + "chainage_m": 100.3, + "facility": "box_culvert", + "options": {"outlet_revet_form": "돌쌓기(찰)", "outlet_revet_height_m": 3.5}, + }, +] +CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 85.0, 90.0, 95.0, 100.0, 105.0] + +# 같은 키 — 옆 측점 연동(88.5 · 폭 10 · 주인 88)이 90 m 에 더 가깝지만 소유(92.4 · 주인 90)가 먼저 +SAME_KEY_POINTS = [ + {"chainage_m": 88.5, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}}, + {"chainage_m": 92.4, "facility": "ford_pavement", "options": {"ford_width_m": 0.1}}, +] +SAME_KEY_CHAINAGES = [85.0, 88.0, 90.0, 95.0] + + +def _attach(tmp_path: Path, points: list[dict], chainages: list[float]) -> list[dict]: + target = pipe_points_file(tmp_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8") + sections = [{"chainage_m": value} for value in chainages] + attach_culvert_sets(tmp_path, sections) + return sections + + +def _at(sections: list[dict], chainage: float) -> dict: + return next(s for s in sections if s["chainage_m"] == chainage) + + +@pytest.fixture() +def sections(tmp_path: Path) -> list[dict]: + return _attach(tmp_path, POINTS, CHAINAGES) + + +def test_연동_물넘이_범위_안_자기_관_함께(sections): + at95 = _at(sections, 95.0) + assert at95["culvert"]["chainage_m"] == 95.0 + assert at95["culvert"]["type"] == "pipe" + assert at95["culvert"]["chute"]["structure"] == "도수로" + assert at95["ford_pavement"]["chainage_m"] == 90.0 + assert set(_at(sections, 85.0)) == {"chainage_m", "ford_pavement"} + + +def test_흙막이는_자기_관에_밀림_없으면_그대로(sections): + at100 = _at(sections, 100.0) + assert at100["box"]["chainage_m"] == 100.3 + assert at100["culvert"]["chainage_m"] == 100.0 + assert "body" not in at100["culvert"] # 관 — 흙막이 세트 아님 + at60 = _at(sections, 60.0) + assert at60["ford"]["chainage_m"] == 60.0 + assert at60["culvert"]["chainage_m"] == 60.0 # 세월교 흙막이 + + +def test_같은_키는_소유_측점_먼저(tmp_path): + sections = _attach(tmp_path, SAME_KEY_POINTS, SAME_KEY_CHAINAGES) + assert _at(sections, 90.0)["ford_pavement"]["chainage_m"] == 92.4 + assert _at(sections, 85.0)["ford_pavement"]["chainage_m"] == 88.5 + + +def test_수량은_주인_측점_한_번(sections): + """소비처 규칙 — 시설 `chainage_m` 의 가장 가까운 측점만 셈.""" + owner = { + p["chainage_m"]: min(CHAINAGES, key=lambda c: abs(c - p["chainage_m"])) for p in POINTS + } + counted: dict[tuple[str, float], int] = {} + for section in sections: + for key in ("culvert", "ford", "box", "ford_pavement"): + value = section.get(key) + if value and owner[value["chainage_m"]] == section["chainage_m"]: + name = "guard" if key == "culvert" and "body" in value else key + counted[(name, value["chainage_m"])] = ( + counted.get((name, value["chainage_m"]), 0) + 1 + ) + assert counted == { + ("ford_pavement", 20.0): 1, + ("ford_pavement", 90.0): 1, + ("culvert", 95.0): 1, # 관 연장 · 도수로 — 95 m 한 번 + ("ford", 60.0): 1, + ("guard", 60.0): 1, + ("culvert", 100.0): 1, + ("box", 100.3): 1, + } + # 박스 흙막이는 자기 관에 밀려 어디에도 안 실림 — 관 연장 · 수량을 두 번 세지 않음 + assert not any(s.get("culvert", {}).get("chainage_m") == 100.3 for s in sections) + + +@pytest.mark.skipif(not mirror.TSC.is_file(), reason="프론트엔드 의존성 미설치") +@pytest.mark.parametrize( + ("points", "chainages"), [(POINTS, CHAINAGES), (SAME_KEY_POINTS, SAME_KEY_CHAINAGES)] +) +def test_파이썬_TS_짝_같은_값(tmp_path, monkeypatch, points, chainages): + expected = _attach(tmp_path / "py", points, chainages) + monkeypatch.setattr(mirror, "POINTS", points) + monkeypatch.setattr(mirror, "CHAINAGES", chainages) + assert mirror._ts_sections(tmp_path) == expected