From 37a738aff48cb1e9e243dd4ac1c2c33c7d17fc59 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 22:44:41 +0900 Subject: [PATCH] =?UTF-8?q?fix(458=20B06=20=EB=85=B8=EC=84=A0=20=EB=B0=94?= =?UTF-8?q?=EB=80=9C):=20=EC=A3=BC=EC=9D=B8=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=83=81=ED=95=9C=200.51=20m=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=EC=A2=85=EC=A0=90=20=EB=B0=96=20=EC=8B=9C=EC=84=A4=20?= =?UTF-8?q?=EC=95=88=20=EC=96=B9=EC=9D=8C=20=C2=B7=20=EA=B0=88=EC=95=84?= =?UTF-8?q?=EB=81=BC=EC=9B=80=20grid=20=EC=A7=80=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EB=8A=98=20=EB=8B=A4=EC=8B=9C=20=EC=96=B9=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=3D=20=EA=B3=84=ED=9A=8D=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관 세트 주인 측점 = 0.51 m 안 가장 가까운 측점(TS attachCulvertSets ↔ 파이썬 attach_culvert_specs) · 넘으면 연동 이웃에도 안 얹고 missed - 형상 얹기 한 벌(attachShapes ↔ attach_shapes) — 시종점 측점 자리인데 좌표가 노선 진행 반대쪽 0.51 m 넘게 바깥 = 「노선 밖」 · 안 얹음 · 안 얹은 것 돌려줌(서버 = 로그 경고) - 측점 갈아끼움 — 격자 아닌 자리면 앞 횡단 grid 표시 지움 · 갈아끼움 뒤 가게 초안 없어도 늘 다시 얹음 · 카드 · 종단 측점 이름 = 계획 제목 · 안 얹은 시설 알림(바뀐 때 한 번) - 시험: test_458_swap_attach(재현 A · D) · test_323_4 거울 moved 경우(상한 · 시종점 밖 · 연동 폭) · test_299 같은 키 견본 = 주인 0.51 m 안으로 · 273_2 흉내 돌려줌 Co-Authored-By: Claude Opus 5.5 --- B06_Section/B06_Section_Engine_Attach.py | 53 ++++- B06_Section/B06_Section_Engine_Culvert.py | 28 +-- B06_Section/B06_Section_Section_Drafts.ts | 40 +++- B06_Section/B06_Section_Section_Store.ts | 2 +- B06_Section/logic/B06_Section_Station_Swap.ts | 5 +- common_util/common_util_culvert_sets.ts | 18 +- common_util/common_util_structure_attach.ts | 52 ++++- .../tester/helper_273_2_preview_stations.cjs | 1 + resources/tester/helper_323_4_attach.cjs | 4 +- resources/tester/helper_458_swap_attach.cjs | 192 ++++++++++++++++++ resources/tester/test_299_ford_span_pipe.py | 13 +- resources/tester/test_323_4_attach_mirror.py | 48 ++++- resources/tester/test_458_swap_attach.py | 66 ++++++ 13 files changed, 475 insertions(+), 47 deletions(-) create mode 100644 resources/tester/helper_458_swap_attach.cjs create mode 100644 resources/tester/test_458_swap_attach.py diff --git a/B06_Section/B06_Section_Engine_Attach.py b/B06_Section/B06_Section_Engine_Attach.py index 6ceebed9c..af14d1681 100644 --- a/B06_Section/B06_Section_Engine_Attach.py +++ b/B06_Section/B06_Section_Engine_Attach.py @@ -9,6 +9,7 @@ TS 짝 = `common_util/common_util_structure_attach.ts` `attachShapes`(화면 = from __future__ import annotations import logging +import math from pathlib import Path from typing import Any @@ -28,6 +29,7 @@ from common_util.common_util_structure_pavement import ( pavement_specs_from, ) from common_util.common_util_structure_station import ( + PIPE_MATCH_M, SAME_STATION_M, blank_keys, plan_stations, @@ -78,12 +80,54 @@ def _attach_blanks(sections: list[dict[str, Any]], pipes: list[Any], structures: section["blank"] = labels -def attach_shapes(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None: - """측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침).""" +def _finite(value: Any) -> bool: + return isinstance(value, (int, float)) and math.isfinite(value) + + +def _outside_route(pipe: Any, sections: list[dict[str, Any]]) -> bool: + """시종점 밖으로 밀린 관인가(458 · TS `outsideRoute` 짝) — 시종점 측점 자리인데 좌표가 노선 진행 + 반대쪽으로 `PIPE_MATCH_M` 넘게 바깥(옆 거리는 안 봄 · 좌표 없으면 아님).""" + placed = sorted( + ( + s + for s in sections + if all(_finite(s.get(k)) for k in ("chainage_m", "center_x", "center_y")) + ), + key=lambda s: s["chainage_m"], + ) + x, y = getattr(pipe, "x", None), getattr(pipe, "y", None) + if len(placed) < 2 or not (_finite(x) and _finite(y)): + return False + for end, nxt in ((placed[0], placed[1]), (placed[-1], placed[-2])): + if abs(end["chainage_m"] - float(pipe.chainage_m)) >= PIPE_MATCH_M: + continue + dx, dy = nxt["center_x"] - end["center_x"], nxt["center_y"] - end["center_y"] + span = math.hypot(dx, dy) + if ( + span > 0 + and ((x - end["center_x"]) * dx + (y - end["center_y"]) * dy) / span <= -PIPE_MATCH_M + ): + return True + return False + + +def attach_shapes( + sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any] +) -> dict[str, list[float]]: + """측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침) · + 시종점 밖 관 · 주인 측점 먼 관은 안 얹고 돌려줌(458 · `{"outside", "far"}`).""" for section in sections: for key in (*SHAPE_KEYS, "blank"): section.pop(key, None) - attach_culvert_specs(sections, culvert_sets_from(pipes)) + misses: dict[str, list[float]] = {"outside": [], "far": []} + kept = [] + for pipe in pipes: + if _outside_route(pipe, sections): + misses["outside"].append(float(pipe.chainage_m)) + else: + kept.append(pipe) + pipes = kept + attach_culvert_specs(sections, culvert_sets_from(pipes), misses["far"]) # 관 세트가 선 측점은 벽을 안 얹음(`attach_wall_specs`) — 차례 = 화면과 같음 attach_wall_specs(sections, wall_specs_from(structures)) attach_pavement_specs( @@ -92,6 +136,9 @@ def attach_shapes(sections: list[dict[str, Any]], pipes: list[Any], structures: ) attach_underdrain_specs(sections, underdrains_from(structures)) _attach_blanks(sections, pipes, structures) + if misses["outside"] or misses["far"]: + logger.warning("B06 시설 그림 안 얹음 — 노선 밖 %s · 측점 멂 %s", *misses.values()) + return misses def attach_saved_shapes(project_root: Path, sections: list[dict[str, Any]]) -> None: diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 4e895efe1..2ff50745b 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -31,7 +31,7 @@ from typing import Any from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from common_util.common_util_drainage_pipes import parse_pipe_points from common_util.common_util_structure_catalog import catalog_row -from common_util.common_util_structure_station import EDGE_TOLERANCE_M +from common_util.common_util_structure_station import EDGE_TOLERANCE_M, PIPE_MATCH_M from config.config_system import ( DRAINAGE_CACHE_DIRNAME, DRAINAGE_EDITS_DIRNAME, @@ -641,19 +641,16 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]] def attach_culvert_specs( - cross_sections: list[dict[str, Any]], sets: dict[float, dict[str, Any]] + cross_sections: list[dict[str, Any]], + sets: dict[float, dict[str, Any]], + missed: list[float] | None = None, ) -> int: - """세트를 측점에 얹는다(TS `attachCulvertSets` 짝). 얹은 개수를 돌려준다.""" + """세트를 측점에 얹음(TS `attachCulvertSets` 짝) · 얹은 수 · 주인 먼 세트 = `missed`.""" if not sets: return 0 attached = 0 - # ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측). - # 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다 - # (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 관 440.241 은 **측점 440.0** 위에 선다. - # ⇒ 0.02m 로만 보면 그런 관은 **어느 측점에도 안 붙어** 횡단도에 안 서고 길이도 안 실려 - # B08 이 「연장 없음」으로 막는다(실측: 배수관 넷이 그렇게 금액에서 빠져 있었다). - # ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다. 하나만 고르므로 - # 두 번 세지 않고, 스냅 폭이 바뀌어도 따라간다. + # 주인 측점 = 가장 가까운 측점 하나(`PIPE_MATCH_M` 안 · 정수 m 측점 위 소수 관도) · + # 더 멀면 그 시설 측점이 없음(노선 바뀜) — 연동 이웃에도 안 얹음. owner: dict[float, float] = {} for pipe_chainage in sets: nearest = None @@ -663,8 +660,10 @@ def attach_culvert_specs( continue if nearest is None or abs(value - pipe_chainage) < abs(nearest - pipe_chainage): nearest = value - if nearest is not None: + if nearest is not None and abs(nearest - pipe_chainage) < PIPE_MATCH_M: owner[pipe_chainage] = nearest + elif missed is not None: + missed.append(pipe_chainage) for section in cross_sections: chainage = _number(section.get("chainage_m"), None) if chainage is None: @@ -674,6 +673,8 @@ def attach_culvert_specs( # 같은 키끼리는 소유 측점 것 → 흙막이 아닌 자기 시설 → 가까운 것 → 관 지점 차례. best: dict[str, tuple[tuple[Any, ...], dict[str, Any]]] = {} for order, (pipe_chainage, spec) in enumerate(sets.items()): + if pipe_chainage not in owner: + continue # 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다(`_SPAN_LINKED_TYPES`). reach = _CHAINAGE_TOLERANCE_M if spec.get("type") in _SPAN_LINKED_TYPES: @@ -690,9 +691,8 @@ def attach_culvert_specs( 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곳 실렸던 자리). + # ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹음 — 없으면 소비처가 소유 측점을 + # 못 가려 **같은 시설을 여러 측점에서 셈**(폭 절반 연동 · 관 9개에 길이 10곳). best[key] = (rank, {**value, "chainage_m": pipe_chainage}) for key, (_, value) in best.items(): section[key] = value diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 4f36cdef8..22dad9da0 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -21,7 +21,7 @@ import { import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { readPlusDraft } from "../B05_Profile/B05_Profile_Api_PlusStations"; import { registryDefaults } from "@util/common_util_culvert_sets"; -import { attachShapes } from "@util/common_util_structure_attach"; +import { attachShapes, type AttachMisses } from "@util/common_util_structure_attach"; import { pipeStationMarks, planStations, @@ -100,23 +100,53 @@ function statusOf( /** 초안으로 그림 자료를 얹은 상세 — 초안이 없어지면 저장분으로 한 번 다시 얹음. */ const drafted = new WeakSet(); +/** 프로젝트마다 마지막으로 알린 안 얹은 시설 — 같은 것은 다시 안 알림. */ +const warned = new Map(); + +/** 안 얹은 시설 알림(458) — 시종점 밖으로 밀림 · 주인 측점이 멂(바뀐 때만 한 번). */ +function warnMisses(projectId: string, misses: AttachMisses): void { + const sign = JSON.stringify(misses); + if (warned.get(projectId) === sign) return; + warned.set(projectId, sign); + const parts = [ + misses.outside.length ? `노선 밖 ${misses.outside.length} 곳` : "", + misses.far.length ? `제 측점 없음 ${misses.far.length} 곳` : "", + ].filter(Boolean); + // 알림 틀은 늦게 불러옴(이 모듈을 쓰는 Node 시험이 화면 모듈 없이 돎) + if (parts.length) + void import("@ui/ui_template_elements").then(({ showToast }) => + showToast(`시설 그림을 얹지 않음 — ${parts.join(" · ")}`, "warning", 6000), + ); +} /** * 모든 측점 그림 자료 = 형상 얹기 한 벌(323-4 · 서버 `attach_shapes` 짝) — 현황 = 초안 ?? 저장분 · - * 저장 전 그림 = 저장 뒤 그림. 초안이 없고 앞서 초안으로 얹은 적도 없으면 서버가 얹은 그대로. + * 저장 전 그림 = 저장 뒤 그림. 초안이 없고 앞서 초안으로 얹은 적도 없으면 서버가 얹은 그대로 · + * 측점 갈아끼움(`swapped`)은 늘 다시 얹고 카드 이름도 계획 제목으로(앞 횡단 그림 · 이름을 안 들고 감 · 458). */ export async function withDraftShapes( detail: SectionDetailResponse, projectId: string, + swapped = false, ): Promise { const pipesDraft = readPendingPipes(projectId); const structuresDraft = readPendingStructures(projectId); const drafts = !!(pipesDraft || structuresDraft); - if (!drafts && !drafted.has(detail)) return; + if (!drafts && !swapped && !drafted.has(detail)) return; const inputs = await statusOf(projectId, pipesDraft, structuresDraft); if (!inputs) return; - attachShapes(detail.cross_sections as unknown as Array>, ...inputs); - if (drafts) drafted.add(detail); + const sections = detail.cross_sections as unknown as Array>; + warnMisses(projectId, attachShapes(sections, ...inputs)); + if (swapped) { + const [pipes, structures, types] = inputs; + const planned = planStations(pipes, structures, types, registryDefaults(types)); + for (const section of [...detail.cross_sections, ...(detail.longitudinal?.stations ?? [])]) { + const hit = planned.find((station) => near(station.chainage_m, section.chainage_m)); + if (hit) section.structure = hit.title; + else delete section.structure; + } + } + if (drafts || swapped) drafted.add(detail); else drafted.delete(detail); } diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 538a19724..7fd208459 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -244,7 +244,7 @@ export async function swapSectionStations( // 메모리만 — 세션 상세 = 저장 정본만(387-7 · 새로고침 · B06 바로 열기 = 묶음이 다시 갈아끼움 · 365-1) cache.set(key, swap.detail); supersedeDraftStations(current); // 옛 상세에 돌던 맞춤이 새 열쇠 목록을 덮지 않게(415 H1) - await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4) + await withDraftShapes(swap.detail, projectId, true); // 늘 지금 현황으로 다시 얹음 · 이름 = 계획(323-4 · 458) withStructureAreas(swap.detail); if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음 emitSwap(swap.detail, swap.moves); // 세션 값 옮김 · 다시 그림(어느 보기든) diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts index aa7278010..0fffb9258 100644 --- a/B06_Section/logic/B06_Section_Station_Swap.ts +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -11,6 +11,7 @@ * · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가 * 그 행을 지우고 기본 설계에 [저장] patch 를 얹음(`sync_cross_section_rows` · `compute_default_designs` · 428) · * patch 가 그 칸을 모두 실음(`crossPatchesFromCache` · 445 H2). + * · 격자 아닌 자리 = 앞 횡단의 `grid` 표시를 지움 · 카드 이름은 형상 다시 얹기가 계획 제목으로(458). * · 빠진 측점 = 목록에서 뺌(세션 고침은 열쇠로 남음 · 노선이 바뀐 때만 「노선 밖 고침」 · 부르는 쪽). * · 옛 상세는 그대로 둠 — 새 객체를 돌려줌(쥔 쪽이 갈아끼움). * @@ -238,13 +239,15 @@ export function swapCrossSections( const reseed = (station.kind === "pipe" || station.kind === "structure") && Math.abs(prior.chainage_m - station.chainageM) > FACILITY_RESEED_M; - return { + const next: CrossSection = { ...prior, ...place, uphill_side: uphill, samples, ...(reseed ? { design: reseeded(prior.design, uphill) } : {}), }; + if (!onGrid) delete next.grid; // 격자 자리를 떠난 시설 측점 — 옛 숨은 일반 측점 표시를 안 들고 감(458) + return next; }); const removed = detail.cross_sections .map((section, i) => (used.has(section) ? null : oldKeys[i])) diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 09aecc0ec..62cd85168 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -39,6 +39,8 @@ const SECTION_KEYS: Record = { }; /** 폭 절반만큼 옆 측점에도 걸치는 종류 — 물넘이포장만. */ const SPAN_LINKED_TYPES = new Set(["ford_pavement"]); +/** 주인 측점 거리 상한(m · 458) — `design_selection.PIPE_MATCH_M` · 파이썬 `PIPE_MATCH_M` 짝. */ +const PIPE_MATCH_M = 0.51; export const MIN_PIPE_COVER_M = 0.5; export const CHUTE_THICKNESS_CM = 45.0; @@ -587,18 +589,17 @@ function rankBefore(a: readonly number[], b: readonly number[]): boolean { return at >= 0 && a[at] < b[at]; } -/** 측점 자료에 세트를 얹는다(파이썬 `attach_culvert_sets`). 얹은 개수를 돌려준다. */ +/** 측점 자료에 세트를 얹는다(파이썬 `attach_culvert_specs`). 얹은 개수를 돌려준다 · + * 주인 측점이 상한 밖인 세트는 안 얹고 그 누가거리를 `missed` 에 담음(458). */ export function attachCulvertSets( sections: Array>, sets: ReadonlyMap, + missed?: number[], ): number { if (sets.size === 0) return 0; let attached = 0; - // ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측). - // 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다(횡단 파일명이 - // 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 관 440.241 은 측점 440.0 위에 선다. - // ⇒ 0.02m 로만 보면 그런 관은 어느 측점에도 안 붙어 **그림도 수량도 통째로 빠진다.** - // ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다(짝: 파이썬 `attach_culvert_sets`). + // 주인 측점 = 가장 가까운 측점 하나(0.51 m 안 · 옛 정수 m 측점 위 소수 관도 붙음 · 짝: 파이썬 + // `attach_culvert_specs`) — 더 멀면 그 시설 측점이 없는 것(노선 바뀜) · 연동 이웃에도 안 얹고 경고(458). const owner = new Map(); for (const [pipeChainage] of sets) { let nearest: number | null = null; @@ -609,7 +610,9 @@ export function attachCulvertSets( nearest = value; } } - if (nearest !== null) owner.set(pipeChainage, nearest); + if (nearest !== null && Math.abs(nearest - pipeChainage) < PIPE_MATCH_M) + owner.set(pipeChainage, nearest); + else missed?.push(pipeChainage); } for (const section of sections) { const chainage = num(section.chainage_m, null); @@ -621,6 +624,7 @@ export function attachCulvertSets( let order = 0; for (const [pipeChainage, spec] of sets) { order += 1; + if (!owner.has(pipeChainage)) continue; // 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다. let reach = CHAINAGE_TOLERANCE_M; if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2; diff --git a/common_util/common_util_structure_attach.ts b/common_util/common_util_structure_attach.ts index 4aa882008..b9dd76dc2 100644 --- a/common_util/common_util_structure_attach.ts +++ b/common_util/common_util_structure_attach.ts @@ -93,16 +93,59 @@ function attachBlanks( } } -/** 측점마다 그림 자료를 걷고 현황으로 다시 얹음(파이썬 `attach_shapes` 짝 · 제자리 고침). */ +/** 안 얹은 관 · 시설 누가거리(458) — 시종점 밖으로 밀림 · 주인 측점이 0.51 m 넘게 멂. */ +export interface AttachMisses { + outside: number[]; + far: number[]; +} + +/** 주인 측점 거리 상한(m) — `design_selection.PIPE_MATCH_M` · 파이썬 `PIPE_MATCH_M` 짝. */ +const PIPE_MATCH_M = 0.51; + +/** 시종점 밖으로 밀린 관인가(458) — 시종점 측점 자리인데 좌표가 노선 진행 반대쪽으로 0.51 m 넘게 바깥 + * (따라감이 누가거리를 0 · 끝에 붙여 둠 · 옆 거리는 안 봄 · 좌표 없으면 아님 · 파이썬 `_outside_route` 짝). */ +function outsideRoute( + pipe: { chainage_m: number; x?: number | null; y?: number | null }, + sections: ReadonlyArray>, +): boolean { + const placed = sections + .filter((s) => [s.chainage_m, s.center_x, s.center_y].every(Number.isFinite)) + .sort((a, b) => Number(a.chainage_m) - Number(b.chainage_m)); + if (placed.length < 2 || !Number.isFinite(pipe.x) || !Number.isFinite(pipe.y)) return false; + const ends: Array<[Record, Record]> = [ + [placed[0], placed[1]], + [placed[placed.length - 1], placed[placed.length - 2]], + ]; + return ends.some(([end, next]) => { + if (Math.abs(Number(end.chainage_m) - pipe.chainage_m) >= PIPE_MATCH_M) return false; + const [ex, ey] = [Number(end.center_x), Number(end.center_y)]; + const [dx, dy] = [Number(next.center_x) - ex, Number(next.center_y) - ey]; + const span = Math.hypot(dx, dy); + return span > 0 && ((pipe.x! - ex) * dx + (pipe.y! - ey) * dy) / span <= -PIPE_MATCH_M; + }); +} + +/** 측점마다 그림 자료를 걷고 현황으로 다시 얹음(파이썬 `attach_shapes` 짝 · 제자리 고침) · + * 시종점 밖 관 · 주인 측점 먼 관은 안 얹고 돌려줌(458 · 경고는 부르는 쪽). */ export function attachShapes( sections: Array>, - pipes: ReadonlyArray, + allPipes: ReadonlyArray, structures: ReadonlyArray, types: ReadonlyArray, -): void { +): AttachMisses { for (const section of sections) for (const key of [...SHAPE_KEYS, "blank"]) delete section[key]; + const misses: AttachMisses = { outside: [], far: [] }; + const pipes = allPipes.filter((pipe) => { + const out = outsideRoute(pipe as Parameters[0], sections); + if (out) misses.outside.push(pipe.chainage_m); + return !out; + }); const registry = registryDefaults(types); - attachCulvertSets(sections, buildCulvertSets(pipes as readonly CulvertPipePoint[], registry)); + attachCulvertSets( + sections, + buildCulvertSets(pipes as readonly CulvertPipePoint[], registry), + misses.far, + ); const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape); // 관 세트가 선 측점은 벽을 안 얹음(`attachWallSpecs`) — 차례 = 서버와 같음 attachWallSpecs( @@ -121,4 +164,5 @@ export function attachShapes( ); attachUnderdrainSpecs(sections, underdrainSpecsFrom(structures as UnderdrainInput[])); attachBlanks(sections, pipes, structures, types); + return misses; } diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 2d0fc2310..dccd78300 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -76,6 +76,7 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { const hit = sections.find((s) => Math.abs(s.chainage_m - pipe.chainage_m) < 0.005); if (hit) hit.culvert = true; } + return { outside: [], far: [] }; }, }, // 측점 열쇠 세우기(344-3) — 이 시험 몫 아님 diff --git a/resources/tester/helper_323_4_attach.cjs b/resources/tester/helper_323_4_attach.cjs index 26de1bdb1..92e6efd26 100644 --- a/resources/tester/helper_323_4_attach.cjs +++ b/resources/tester/helper_323_4_attach.cjs @@ -41,9 +41,9 @@ const hash = load( ); catalog.useCatalogDefinition({ types: input.types, ...input.definition }); -const out = { cases: {} }; +const out = { cases: {}, misses: {} }; for (const [name, { sections, pipes, structures }] of Object.entries(input.cases)) { - attach.attachShapes(sections, pipes, structures, input.types); + out.misses[name] = attach.attachShapes(sections, pipes, structures, input.types); out.cases[name] = sections; } diff --git a/resources/tester/helper_458_swap_attach.cjs b/resources/tester/helper_458_swap_attach.cjs new file mode 100644 index 000000000..4f6921462 --- /dev/null +++ b/resources/tester/helper_458_swap_attach.cjs @@ -0,0 +1,192 @@ +/* PLAN 458 노선 바뀜 — 진짜 모듈(따라감 · 측점 · 갈아끼움 · 형상 얹기)을 그 자리에서 트랜스파일해 Node 로 돌림. + * 저장 노선 (0,0)→(100,0)→(200,60) 위 관 · 세월교 · 옹벽 → 시점 당김 / 밀어냄 → 갈아끼움 + 지금 현황으로 다시 얹기. + * 결과 = 표준출력 JSON. 짝 = `test_458_swap_attach.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const cache = new Map(); +function resolveTs(base) { + for (const c of [`${base}.ts`, base, path.join(base, "index.ts")]) + if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; + throw new Error(`모름: ${base}`); +} +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec0) => { + if (/common_util_structure_catalog(\.js)?$/.test(spec0)) + return require(path.join(__dirname, "helper_322_catalog_module.cjs")); + const spec = spec0.replace(/\.js$/, ""); + if (spec.endsWith(".json")) + return JSON.parse(fs.readFileSync(path.join(path.dirname(full), spec), "utf8")); + if (spec.startsWith("@util/")) + return load(resolveTs(path.join(ROOT, "common_util", spec.slice(6)))); + if (spec.startsWith(".")) return load(resolveTs(path.join(path.dirname(full), spec))); + return require(spec); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const P = (...parts) => path.join(ROOT, ...parts); +const follow = load(P("common_util", "common_util_route_follow.ts")); +const SS = load(P("B05_Profile", "logic", "B05_Profile_Sections_Stations.ts")); +const SW = load(P("B06_Section", "logic", "B06_Section_Station_Swap.ts")); +const AT = load(P("common_util", "common_util_structure_attach.ts")); +const def = JSON.parse( + fs.readFileSync(P("resources", "master_template", "구조물정의.json"), "utf8"), +); +const types = def.types + .filter((t) => t.enabled !== false) + .map((t) => ({ + type_id: t.type_id, + group: t.group, + placement: t.placement, + managed_by: t.managed_by, + name: t.name, + options: (t.options || []).map((o) => ({ key: o.key, label: o.label, default: o.default })), + })); + +const INTERVAL = 20; +const xy = (L) => L.map(([x, y]) => ({ x, y })); +function at(L, ch) { + let walked = 0; + for (let i = 1; i < L.length; i++) { + const [a, b] = [L[i - 1], L[i]]; + const span = Math.hypot(b[0] - a[0], b[1] - a[1]); + if (walked + span >= ch) { + const t = (ch - walked) / span; + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + } + walked += span; + } + return L[L.length - 1]; +} +const kindOf = (s) => + s.kind === "end" + ? s.key.endsWith("EP") + ? "ep" + : "bp" + : s.kind === "grid" + ? "regular" + : s.kind === "curve" + ? "curve" + : "irregular"; +/** 저장 상세 — 서버처럼 격자 자리 시설 측점에 `grid` · 설계에 열쇠 메모. */ +const sectionsFrom = (stations) => + stations.map((s) => ({ + station_id: SW.stationIdOf(s.chainageM), + chainage_m: s.chainageM, + label: "", + kind: kindOf(s), + ...(kindOf(s) === "irregular" && s.chainageM % INTERVAL === 0 ? { grid: true } : {}), + center_x: s.x, + center_y: s.y, + center_z: 100, + frame: { left_xy: s.left }, + samples: [{ offset_m: 0, elevation_m: 100, valid: true }], + uphill_side: "left", + design: { memo: s.key, design_elevation_m: 101 }, + })); +const ground = (s) => ({ + ...s, + ground: { centerZ: 100, left: [{ offsetM: 5, z: 101 }], right: [{ offsetM: 5, z: 99 }] }, +}); +const KEYS = ["culvert", "ford", "box", "ford_pavement"]; +const view = (sections) => + sections.map((s) => ({ + chainage_m: s.chainage_m, + kind: s.kind, + grid: !!s.grid, + shapes: Object.fromEntries(KEYS.filter((k) => s[k]).map((k) => [k, s[k].chainage_m])), + })); + +function run(L1, L2, pipes, structures) { + const S1 = SS.buildStations(L1, [], pipes, structures, { types, intervalM: INTERVAL }); + const saved = { longitudinal: { stations: [], length_m: 0 }, cross_sections: sectionsFrom(S1) }; + AT.attachShapes(saved.cross_sections, pipes, structures, types); + const pipesF = follow.projectPipePoints(pipes, xy(L2)); + const structuresF = follow + .followStructures(structures, xy(L2), undefined, INTERVAL) + .map((m) => m.item); + const S2 = SS.buildStations(L2, [], pipesF, structuresF, { types, intervalM: INTERVAL }).map( + ground, + ); + const swap = SW.swapCrossSections( + saved, + S2, + S1.map((s) => s.key), + INTERVAL, + ); + const now = swap.detail.cross_sections.map((s) => ({ ...s })); + const misses = AT.attachShapes(now, pipesF, structuresF, types); + // 가게 초안이 없는 채(저장분 현황) 얹기 — 상한이 옛 자리 관을 이웃 측점에 안 붙임 + const stale = swap.detail.cross_sections.map((s) => ({ ...s })); + const staleMisses = AT.attachShapes(stale, pipes, structures, types); + return { + pipes: pipesF.map((p) => [p.id, p.chainage_m]), + now: view(now), + misses, + staleMisses, + stale: view(stale), + }; +} + +const L1 = [ + [0, 0], + [100, 0], + [200, 60], +]; +const pipe = (id, facility, ch, options) => { + const [x, y] = at(L1, ch); + return { id, facility, chainage_m: ch, x, y, options }; +}; +const pipes = [ + pipe("p1", "pipe", 40, { pipe_diameter_mm: "800" }), // 격자 No.2 위 + pipe("p2", "ford_bridge", 150, { ford_width_m: 10 }), +]; +const [wx, wy] = at(L1, 70); +const wall = { + structure_id: "w1", + type_id: "retaining_wall", + chainage_m: 70, + start_m: 60, + end_m: 80, + x: wx, + y: wy, + options: { height_m: 2, side: "left" }, +}; + +const out = { + // A — 시점 7 m 당김: 관 40 → 47(격자를 떠남) · 세월교 150 → 157 + shift: run( + L1, + [ + [-7, 0], + [100, 0], + [200, 60], + ], + pipes, + [wall], + ), + // D — 시점을 50 m 로 밀어냄: 관 40 = 새 시점 10 m 바깥 → 누가거리 0 + outside: run( + L1, + [ + [50, 0], + [100, 0], + [200, 60], + ], + pipes, + [wall], + ), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_299_ford_span_pipe.py b/resources/tester/test_299_ford_span_pipe.py index d5de86e2a..e38fd3317 100644 --- a/resources/tester/test_299_ford_span_pipe.py +++ b/resources/tester/test_299_ford_span_pipe.py @@ -56,12 +56,13 @@ POINTS = [ ] 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)가 먼저 +# 같은 키 — 옆 측점 연동(89.8 · 폭 10 · 주인 89.7)이 90 m 에 더 가깝지만 소유(90.4 · 주인 90)가 먼저 +# (주인 측점 = 0.51 m 안 · 458) 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}}, + {"chainage_m": 89.8, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}}, + {"chainage_m": 90.4, "facility": "ford_pavement", "options": {"ford_width_m": 0.1}}, ] -SAME_KEY_CHAINAGES = [85.0, 88.0, 90.0, 95.0] +SAME_KEY_CHAINAGES = [85.0, 89.7, 90.0, 95.0] def _attach(tmp_path: Path, points: list[dict], chainages: list[float]) -> list[dict]: @@ -103,8 +104,8 @@ def test_흙막이는_자기_관에_밀림_없으면_그대로(sections): 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 + assert _at(sections, 90.0)["ford_pavement"]["chainage_m"] == 90.4 + assert _at(sections, 85.0)["ford_pavement"]["chainage_m"] == 89.8 def test_수량은_주인_측점_한_번(sections): diff --git a/resources/tester/test_323_4_attach_mirror.py b/resources/tester/test_323_4_attach_mirror.py index a74a5ed55..95068d93b 100644 --- a/resources/tester/test_323_4_attach_mirror.py +++ b/resources/tester/test_323_4_attach_mirror.py @@ -99,9 +99,35 @@ def _sections(until: float) -> list[dict]: PIPES, STRUCTURES = _samples() UNTIL = 15.0 + 30.0 * len(catalog()) + 60 + + +def _moved() -> dict: + """458 노선 바뀜 — 노선 x 축 0 ~ 100 · 측점 10 m 마다(좌표 있음). + + 관 30.4(주인 30 · 0.4 m) · 44(가장 가까운 측점 4 m = 측점 없음) · 0(좌표 -3 = 시점 밖) · + 100(좌표 100.3 · 옆 1.5 = 종점 안) · 물넘이포장 70(폭 24 = 60 · 80 연동) · 55(주인 5 m = 연동도 안 얹음). + """ + pipe = _defaults("pipe") + pave = {**_defaults("ford_pavement"), "ford_width_m": 24} + sections = [ + {"chainage_m": float(at), "center_x": float(at), "center_y": 0.0} + for at in range(0, 101, 10) + ] + pipes = [ + {"id": "a", "chainage_m": 30.4, "x": 30.4, "y": 0.0, "facility": "pipe", "options": pipe}, + {"id": "b", "chainage_m": 44.0, "x": 44.0, "y": 0.0, "facility": "pipe", "options": pipe}, + {"id": "c", "chainage_m": 0.0, "x": -3.0, "y": 0.0, "facility": "pipe", "options": pipe}, + {"id": "d", "chainage_m": 100.0, "x": 100.3, "y": 1.5, "facility": "pipe", "options": pipe}, + {"id": "e", "chainage_m": 70.0, "facility": "ford_pavement", "options": pave}, + {"id": "f", "chainage_m": 55.0, "facility": "ford_pavement", "options": pave}, + ] + return {"sections": sections, "pipes": pipes, "structures": []} + + CASES = { "all": {"sections": _sections(UNTIL), "pipes": PIPES, "structures": STRUCTURES}, "empty": {"sections": _sections(30), "pipes": [], "structures": []}, + "moved": _moved(), } @@ -122,22 +148,36 @@ def ts() -> dict: return json.loads(out.stdout) -def _py(case: dict) -> list[dict]: +def _py(case: dict) -> tuple[list[dict], dict]: sections = copy.deepcopy(case["sections"]) pipes = parse_pipe_points(case["pipes"]) structures = [StructureInstance.model_validate(s) for s in case["structures"]] - attach_shapes(sections, pipes, structures) - return json.loads(json.dumps(sections)) + misses = attach_shapes(sections, pipes, structures) + return json.loads(json.dumps(sections)), misses def test_mirror_python_equals_ts(ts: dict) -> None: for name, case in CASES.items(): - py, js = _py(case), ts["cases"][name] + (py, misses), js = _py(case), ts["cases"][name] + assert misses == ts["misses"][name], name assert len(py) == len(js), name for left, right in zip(py, js): assert left == right, (name, left["chainage_m"]) +def test_owner_cap_and_outside(ts: dict) -> None: + """458 — 주인 측점 0.51 m 상한 · 시종점 밖 = 안 얹고 경고 · 연동 이웃 = 폭 절반.""" + by_at = {s["chainage_m"]: s for s in ts["cases"]["moved"]} + assert ts["misses"]["moved"] == {"outside": [0.0], "far": [44.0, 55.0]} + assert by_at[30.0]["culvert"]["chainage_m"] == 30.4 + assert by_at[100.0]["culvert"]["chainage_m"] == 100.0 # 종점 0.3 m 넘음 · 옆 1.5 m = 노선 안 + assert "culvert" not in by_at[0.0] and "culvert" not in by_at[40.0] + assert "culvert" not in by_at[50.0] + linked = {at for at, s in by_at.items() if "ford_pavement" in s} + assert linked == {60.0, 70.0, 80.0} + assert all(by_at[at]["ford_pavement"]["chainage_m"] == 70.0 for at in linked) + + def test_every_shape_lands(ts: dict) -> None: keys = {key for section in ts["cases"]["all"] for key in section if key != "chainage_m"} assert { diff --git a/resources/tester/test_458_swap_attach.py b/resources/tester/test_458_swap_attach.py new file mode 100644 index 000000000..5ec3b1fcc --- /dev/null +++ b/resources/tester/test_458_swap_attach.py @@ -0,0 +1,66 @@ +"""458 노선 바뀜 — B06 갈아끼움 + 지금 현황으로 다시 얹기 · 주인 측점 거리 상한 · 시종점 밖. + +못박는 것 + - 시점 당김(재현 A 꼴): 관 · 세월교 그림 = 제 측점 하나씩(따라간 누가거리) · 격자를 떠난 관 측점 `grid` 지움. + - 가게 초안 없는 채(저장분 현황) 얹어도 옛 자리 세월교가 이웃 일반 측점에 안 붙음(상한 0.51 m · 측점 없음). + - 시점 밖으로 밀린 관(재현 D 꼴) = 시점 측점에 안 얹고 「노선 밖」. + +Node 헬퍼 `helper_458_swap_attach.cjs` 가 진짜 모듈(따라감 · 측점 · 갈아끼움 · 형상 얹기)을 부름. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_458_swap_attach.cjs")], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _shaped(sections: list[dict]) -> dict[float, dict]: + return {s["chainage_m"]: s["shapes"] for s in sections if s["shapes"]} + + +def test_시설마다_제_측점_하나(out): + shift = out["shift"] + assert shift["pipes"] == [["p1", 47], ["p2", 157]] + assert _shaped(shift["now"]) == {47: {"culvert": 47}, 157: {"ford": 157}} + assert shift["misses"] == {"outside": [], "far": []} + + +def test_격자_떠난_시설_측점_grid_지움(out): + by_at = {s["chainage_m"]: s for s in out["shift"]["now"]} + assert by_at[47]["kind"] == "irregular" and by_at[47]["grid"] is False + + +def test_저장분_현황이어도_이웃에_안_붙음(out): + shift = out["shift"] + assert shift["staleMisses"]["far"] == [150] + assert all("ford" not in shapes for shapes in _shaped(shift["stale"]).values()) + + +def test_시점_밖_관은_안_얹고_노선_밖(out): + outside = out["outside"] + assert ["p1", 0] in outside["pipes"] + assert outside["misses"] == {"outside": [0], "far": []} + assert _shaped(outside["now"]) == {100: {"ford": 100}} + assert {s["chainage_m"]: s["grid"] for s in outside["now"]}[20] is True # 격자 자리 옹벽 측점