From 37a738aff48cb1e9e243dd4ac1c2c33c7d17fc59 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 22:44:41 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(458=20B06=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EB=B0=94=EB=80=9C):=20=EC=A3=BC=EC=9D=B8=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=20=EA=B1=B0=EB=A6=AC=20=EC=83=81=ED=95=9C=200.51=20m=20=C2=B7?= =?UTF-8?q?=20=EC=8B=9C=EC=A2=85=EC=A0=90=20=EB=B0=96=20=EC=8B=9C=EC=84=A4?= =?UTF-8?q?=20=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 # 격자 자리 옹벽 측점 From 182de3b2e3987b3266ad428f8532c33a558e5766 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 22:49:09 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(461=20B05=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EC=A7=81=EC=84=A0=ED=99=94):=20=ED=81=B4=EB=A6=AD=20=3D=20?= =?UTF-8?q?=EB=8A=98=20=EC=B8=A1=EC=A0=90=20=EA=B3=A0=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EB=81=9D=20=EC=9E=90=EB=8F=99=20R=20=EC=A7=80=EC=9B=80=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20L=20=C2=B7=20=EC=9D=B4=EB=AF=B8=20?= =?UTF-8?q?=EC=A7=81=EC=84=A0=20=EB=B2=94=EC=9C=84=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=C2=B7=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EB=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 직선화 첫 클릭이 직선 안이면 직선 통째 고름(붉은 강조)으로 새던 갈림 지움 · 빈 곳 클릭 측점선에 붙음 - 이미 직선인 범위 = 부분 구간 고름(보라 띠 · 안내) · ▲▼ = 가운데 + 양 끝 기본 L 곡선 - 직선화 · 자유지정 끝 R 이 저장 자동 R 이면 지움 = 끝 곡선 L 20 · 사용자 R 은 둠 - 법정 미달 판정 오차 1e-3 짝(TS · 파이썬) — L 19.99998 거짓 미달 없앰 - 새 도우미 design_profile_range_band — 직선화 · 자유지정 범위 단계 실시간 띠 · 시작–끝 · 길이 글 - Esc = 시작만 취소 · 모드 유지 · 고름 풀기로 안 샘 - 시험 test_271_straighten_pick (거울 포함) Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_profile_free_view.ts | 26 +- .../design/design_profile_range_band.ts | 149 +++++++++ .../design/logic/design_profile_free.ts | 38 ++- .../B05_Profile_Engine_Grade_CurveLaw.py | 7 +- .../B05_Profile_UI_Profile_Panel_Tools.ts | 70 +++- B05_Profile/B05_Profile_UI_Profile_Tools.ts | 30 +- B05_Profile/B05_Profile_UI_Style_Table.css | 9 + .../logic/B05_Profile_Profile_CurveLaw.ts | 7 +- .../B05_Profile_UI_Profile_Straighten.ts | 40 ++- .../tester/helper_271_straighten_pick.cjs | 306 ++++++++++++++++++ resources/tester/test_271_straighten_pick.py | 196 +++++++++++ 11 files changed, 825 insertions(+), 53 deletions(-) create mode 100644 A00_Common/design/design_profile_range_band.ts create mode 100644 resources/tester/helper_271_straighten_pick.cjs create mode 100644 resources/tester/test_271_straighten_pick.py diff --git a/A00_Common/design/design_profile_free_view.ts b/A00_Common/design/design_profile_free_view.ts index 48584c4ee..7c9d1adf7 100644 --- a/A00_Common/design/design_profile_free_view.ts +++ b/A00_Common/design/design_profile_free_view.ts @@ -18,6 +18,7 @@ import { type ChartWindow, type FreeDraw, } from "./logic/design_profile_free"; +import { appendRangeBand } from "./design_profile_range_band"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -100,29 +101,10 @@ export function createFreeView(free: FreeDraw): FreeView { height: heightPx, viewBox: `0 0 ${widthPx} ${heightPx}`, }) as SVGSVGElement; - if (marks.band) { - const [left, right] = marks.band.map(x); - svg.append( - svgEl("rect", { - class: "b05-profile-freemark__band", - x: left, - y: 0, - width: Math.max(0, right - left), - height: heightPx, - }), - ); + // 범위 띠 · 세로선 · 길이 글 = [직선화]와 한 벌(271-3) — 고르는 중엔 마우스 측점까지 + if (marks.edges.length) { + appendRangeBand(svg, x, heightPx, marks.edges[0], marks.edges[1] ?? null); } - marks.edges.forEach((chainage) => - svg.append( - svgEl("line", { - class: "b05-profile-freemark__edge", - x1: x(chainage), - x2: x(chainage), - y1: 0, - y2: heightPx, - }), - ), - ); marks.segments.forEach((segment) => { const x1 = x(segment.from.chainage_m); const x2 = x(segment.to.chainage_m); diff --git a/A00_Common/design/design_profile_range_band.ts b/A00_Common/design/design_profile_range_band.ts new file mode 100644 index 000000000..a9b686a1e --- /dev/null +++ b/A00_Common/design/design_profile_range_band.ts @@ -0,0 +1,149 @@ +/* ============================================================================= + * A00_Common/design/design_profile_range_band.ts + * 종단 범위 실시간 띠 — 시작 세로선을 고른 뒤 마우스 측점까지 띠 · 세로선 · 「시작–끝 · 길이」 글(271-2 · 3). + * + * [직선화]는 `createRangeBandView` 로 그래프에 붙이고, [자유지정]은 `design_profile_free_view` 가 + * 같은 `appendRangeBand` 로 그린다 — 띠 한 벌. 마우스 자리는 가장 가까운 측점선에 붙음 · 그래프 밖 = + * 시작선만 · 다시 들어오면 이어짐. 색 · 굵기는 자유지정 범위 띠(`b05-profile-freemark__*`)와 같음. + * ========================================================================== */ + +import { snapStation } from "./logic/design_profile_free"; +import type { FreeChartGeom } from "./design_profile_free_view"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +function svgEl(tag: string, attrs: Record): SVGElement { + const element = document.createElementNS(SVG_NS, tag); + Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value))); + return element; +} + +/** 범위 띠를 붙인다 — 끝이 없거나 시작과 같으면 시작선만 · 반대 방향도 같은 띠. */ +export function appendRangeBand( + svg: SVGElement, + x: (chainageM: number) => number, + heightPx: number, + startM: number, + endM: number | null, +): void { + const ends = + endM === null || Math.abs(endM - startM) < 1e-6 + ? [startM] + : [Math.min(startM, endM), Math.max(startM, endM)]; + if (ends.length === 2) { + const [left, right] = ends.map(x); + svg.append( + svgEl("rect", { + class: "b05-profile-freemark__band", + x: left, + y: 0, + width: Math.max(0, right - left), + height: heightPx, + }), + ); + const label = svgEl("text", { + class: "b05-profile-freemark__span", + x: (left + right) / 2, + y: 14, + "text-anchor": "middle", + }); + label.textContent = `${ends[0].toFixed(1)}–${ends[1].toFixed(1)}m · ${(ends[1] - ends[0]).toFixed(1)}m`; + svg.append(label); + } + ends.forEach((chainage) => + svg.append( + svgEl("line", { + class: "b05-profile-freemark__edge", + x1: x(chainage), + x2: x(chainage), + y1: 0, + y2: heightPx, + }), + ), + ); +} + +export interface RangeBandView { + /** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */ + mount: (geom: FreeChartGeom | null, active: boolean) => void; + redraw: () => void; +} + +/** 범위 띠 화면 — `source(마우스 측점)` 가 [시작, 끝 | null] 을 주면 그림 · null 이면 지움. */ +export function createRangeBandView( + source: (hoverM: number | null) => [number, number | null] | null, +): RangeBandView { + let geom: FreeChartGeom | null = null; + let active = false; + let hover: number | null = null; + /** 마지막 마우스 x(화면) — 스크롤 · 다시 그림 뒤에도 커서 밑 측점을 다시 잡는다. */ + let clientX: number | null = null; + let overlay: SVGElement | null = null; + let listeners: AbortController | null = null; + + function redraw(): void { + overlay?.remove(); + overlay = null; + const range = geom && active ? source(hover) : null; + if (!geom || !range) return; + const { widthPx, heightPx } = geom; + overlay = svgEl("svg", { + class: "b05-profile-freemark", + width: widthPx, + height: heightPx, + viewBox: `0 0 ${widthPx} ${heightPx}`, + }); + appendRangeBand(overlay, geom.x, heightPx, range[0], range[1]); + geom.chart.append(overlay); + } + + /** 커서 밑 측점을 다시 잡는다 — 바뀌었을 때만 다시 그림. */ + function track(): void { + let next: number | null = null; + if (geom && clientX !== null) { + const at = geom.toChainage(clientX - geom.chart.getBoundingClientRect().left); + next = Number.isFinite(at) ? snapStation(geom.stations, at) : null; + } + if (next === hover) return; + hover = next; + redraw(); + } + + function mount(next: FreeChartGeom | null, on: boolean): void { + listeners?.abort(); + listeners = null; + if (next) geom = next; + active = on; + if (!on) [hover, clientX] = [null, null]; + if (!geom || !on || !geom.chart.isConnected) { + overlay?.remove(); + overlay = null; + return; + } + listeners = new AbortController(); + const { signal } = listeners; + const chart = geom.chart; + chart.addEventListener( + "mousemove", + (event) => { + clientX = event.clientX; + track(); + }, + { signal }, + ); + chart.addEventListener( + "mouseleave", + () => { + clientX = null; + track(); + }, + { signal }, + ); + geom.scroller.addEventListener("scroll", track, { signal, passive: true }); + hover = null; + track(); + redraw(); + } + + return { mount, redraw }; +} diff --git a/A00_Common/design/logic/design_profile_free.ts b/A00_Common/design/logic/design_profile_free.ts index 77d0ae4a9..a19cd6a39 100644 --- a/A00_Common/design/logic/design_profile_free.ts +++ b/A00_Common/design/logic/design_profile_free.ts @@ -4,7 +4,7 @@ * * 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은 * 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽 - * 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) = + * 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소(범위 첫 측점만 = 시작만 · 271-3) · ↶ ↷(Backspace = ↶) = * 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음. * * 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고 @@ -27,6 +27,7 @@ import { import { baseElevationAt, defaultCurveLength, + dropAutoEndRadii, pinEnds, } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten"; import { isStraightNode } from "../../../B05_Profile/logic/B05_Profile_Profile_Straight"; @@ -120,12 +121,14 @@ function elevationOnPath(path: FreeVertex[], chainageM: number): number { /** * 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를 - * 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음). + * 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음) · + * 끝 R 이 저장 자동 R 이면 지워 기본 L(271-1). */ export function buildFreeEdits( base: AlignmentBase, edits: AlignmentEdits, vertices: FreeVertex[], + autoRadii: Readonly> = {}, ): AlignmentEdits { const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m); if (path.length < 2) return edits; @@ -148,6 +151,7 @@ export function buildFreeEdits( pinEnds(base, current, [fromM, toM]).forEach(([key, value]) => { offsets[key] = Number(value.toFixed(6)); }); + dropAutoEndRadii(radii, [fromM, toM], autoRadii); inner.forEach((chainage, key) => { const target = elevationOnPath(path, chainage); offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6)); @@ -191,6 +195,8 @@ export interface FreeDrawDeps { edits: () => AlignmentEdits; /** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */ apply: (next: AlignmentEdits) => boolean | void; + /** 저장 자동 R — 끝 R 이 이것과 같으면 지움(271-1). */ + autoRadii?: () => Readonly>; /** 모드를 끈다(적용 뒤 · Esc). */ close: () => void; /** 단계 · 안내가 바뀜. */ @@ -222,6 +228,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { let anchor: number | null = null; let points: FreeVertex[] = []; let target: FreeVertex | null = null; + /** range 단계 첫 측점 뒤 마우스 측점 — 실시간 띠 끝(271-3). */ + let hoverStation: number | null = null; /** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */ interface Snap { phase: FreePhase; @@ -249,6 +257,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { anchor = null; points = []; target = null; + hoverStation = null; past = []; future = []; } @@ -296,7 +305,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { const z = end === null ? null : heightAt(end); if (!base || end === null || z === null) return; const vertices = [...path(), { chainage_m: end, elevation_m: z }]; - const next = buildFreeEdits(base, deps.edits(), vertices); + const next = buildFreeEdits(base, deps.edits(), vertices, deps.autoRadii?.()); // 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3) if (deps.apply(next) === false) return; reset(); @@ -343,8 +352,19 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { }, hover(pointer) { target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null; + hoverStation = + pointer && phase === "range" && pending !== null + ? snapStation(pointer.stations, pointer.chainageM) + : null; }, key(key) { + // range 단계 첫 측점만 고른 채 Esc = 시작만 취소 · 모드 유지(271-3) + if (key === "Escape" && phase === "range" && pending !== null) { + pending = null; + hoverStation = null; + deps.changed(); + return true; + } if (key === "Escape") { reset(); deps.close(); @@ -381,9 +401,15 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { preview: target !== null && index === line.length - 1, }); } + // 범위 고르는 중엔 첫 측점 → 마우스 측점까지 띠(271-3) · 둘째 클릭 뒤엔 고른 범위로 고정 + const live = + pending !== null && hoverStation !== null && !same(pending, hoverStation) + ? ([Math.min(pending, hoverStation), Math.max(pending, hoverStation)] as [number, number]) + : null; + const band = range ?? live; return { - band: range, - edges: range ? [...range] : pending !== null ? [pending] : [], + band, + edges: band ? [...band] : pending !== null ? [pending] : [], segments, dots, }; @@ -392,7 +418,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { if (phase === "range") { return pending === null ? "자유지정 — 범위 첫 세로 측점선을 고르세요" - : `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`; + : `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요 (Esc 시작 취소)`; } if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)"; return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)"; diff --git a/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py b/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py index e9d454ffd..0090a970f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py +++ b/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py @@ -18,6 +18,8 @@ from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT # 초기값 직선 문턱 R(m) — TS `INITIAL_STRAIGHT_RADIUS_M` 과 같은 값. INITIAL_STRAIGHT_RADIUS_M = float(FOREST_ROAD_PROFILE_ALIGNMENT["initial_straight_radius_m"]) +# 법정 미달 판정 오차(m) — R = L/Δ 되셈 L 19.99998 거짓 미달 막음(271-1) · TS `SHORTFALL_TOLERANCE_M` 짝. +SHORTFALL_TOLERANCE_M = 1e-3 def flat_bends(base_s: np.ndarray, base_z: np.ndarray, curve_length_m: float | None) -> np.ndarray: @@ -60,7 +62,10 @@ def vertical_curve_shortfalls( continue radius = float(curve.get("radius_m", curve.get("r_m", 0.0))) length = float(curve.get("length_m", curve.get("l_m", 0.0))) - if radius < min_radius_m - 1e-6 or length < min_length_m - 1e-6: + if ( + radius < min_radius_m - SHORTFALL_TOLERANCE_M + or length < min_length_m - SHORTFALL_TOLERANCE_M + ): out.append( f"{float(curve['chainage_m']):.1f}m: 종단곡선 R {radius:.0f}m · L {length:.1f}m — " f"법정 최소 R {min_radius_m:.0f}m · L {min_length_m:.0f}m 미달" diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 54e24ff6d..7f57e4b54 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -29,11 +29,13 @@ import { hasStraightRuns, shiftStraightRuns, straightenBetween, + straightSubRun, tiltStraightRun, } from "./logic/B05_Profile_UI_Profile_Straighten"; import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; -import { createFreeDraw } from "../A00_Common/design/logic/design_profile_free"; +import { createFreeDraw, snapStation } from "../A00_Common/design/logic/design_profile_free"; import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; +import { createRangeBandView } from "../A00_Common/design/design_profile_range_band"; import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { stepStructureStation } from "../common_util/common_util_structure_station"; import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store"; @@ -121,17 +123,35 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { alignment: ctx.alignment, edits: ctx.edits, apply: (next) => ctx.applyEdits(next), + autoRadii: ctx.savedRadii, close: () => tools.clear(), changed: ctx.refresh, }); const freeView = createFreeView(free); let lastGeom: FreeChartGeom | null = null; + // [직선화] 실시간 띠(271-2) — 시작 측점 → 마우스 측점 · 고른 부분 구간은 고정 띠. + const bandView = createRangeBandView((hoverM) => { + const start = tools.pendingStation(); + const sub = tools.subRun(); + return start !== null ? [start, hoverM] : sub ? [sub.fromM, sub.toM] : null; + }); + const mountViews = (mode: string): void => { + freeView.mount(lastGeom, mode === "free"); + bandView.mount(lastGeom, mode === "straighten"); + }; const tools = createProfileTools({ onStraighten: (fromM, toM) => { const base = ctx.base(); if (!base) return; - ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM)); + // 이미 직선인 범위 = 부분 구간으로 고름(271-1) — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선 + const alignment = ctx.alignment(); + const sub = alignment ? straightSubRun(alignment, fromM, toM) : null; + if (sub) { + tools.handleRunPick(sub); + return; + } + ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())); tools.clear(); }, onShift: (runs, delta) => { @@ -159,12 +179,12 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { const alignment = ctx.alignment(); return alignment ? hasStraightRuns(alignment) : false; }, - onChanged: ctx.refresh, - onModeChange: (mode) => freeView.mount(lastGeom, mode === "free"), + onChanged: () => (bandView.redraw(), ctx.refresh()), + onModeChange: mountViews, freeHint: () => free.hint(), mountOverlay: (geom) => { lastGeom = geom; - freeView.mount(geom, tools.mode() === "free"); + mountViews(tools.mode()); }, }); @@ -178,15 +198,10 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (mode === "shift") { return tools.handleRunPick(alignment ? detectStraightRun(alignment, chainageM) : null); } - // 직선화 모드에서 첫 클릭이 **이미 직선화된 라인 안쪽**이면 그 라인을 골라 - // 틸팅(가운데 라운드 + 양측 탄젠트) 대상으로 삼는다. - if (tools.pendingStation() === null && alignment) { - const run = detectStraightRun(alignment, chainageM); - if (run && Math.abs(run.fromM - chainageM) > 1e-6 && Math.abs(run.toM - chainageM) > 1e-6) { - return tools.handleRunPick(run); - } - } - return tools.handleStationPick(chainageM); + // 직선화 클릭 = 늘 측점 고름 · 빈 곳은 가장 가까운 측점선에 붙음(271-1) — 직선 안 클릭이 + // 직선 통째 고름(붉은 강조)으로 새던 갈림을 지움. 이미 직선인 범위는 `onStraighten` 이 가름. + const station = lastGeom ? snapStation(lastGeom.stations, chainageM) : null; + return tools.handleStationPick(station ?? chainageM); } function selectedIrregular(): IrregularStation | null { @@ -355,10 +370,33 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { }; window.addEventListener("keydown", onKeyDown); + /* Esc = 도구 몫(271-2 · 3) — 잡는 단계에서 먼저 받아 고름 풀기(`design_pick_escape`)로 안 샘: + * [직선화] 시작 측점 · 부분 구간만 취소(모드 유지) · [자유지정]은 자기 규칙(범위 첫 측점만 = 시작만). */ + const onEscape = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || !ctx.root.isConnected) return; + const target = event.target as HTMLElement | null; + if ( + target?.closest?.("input, select, textarea") || + document.querySelector(".ui-modal__backdrop") + ) { + return; + } + if (tools.mode() === "free") { + free.key("Escape"); + freeView.redraw(); + } else if (!(tools.mode() === "straighten" && tools.cancelPick())) return; + event.preventDefault(); + event.stopPropagation(); + }; + window.addEventListener("keydown", onEscape, true); + const setListening = (on: boolean): void => { window.removeEventListener("keydown", onKeyDown); - if (on) window.addEventListener("keydown", onKeyDown); - else stopHold(); + window.removeEventListener("keydown", onEscape, true); + if (on) { + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keydown", onEscape, true); + } else stopHold(); }; return { tools, history, handleToolPick, setListening }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index b82790a57..bf76034b2 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -7,10 +7,10 @@ * [새 창] 과 같다(공용 토큰 `--btn-bar-*`). [새 창] 은 종단도가 그릴 때마다 맨 앞에 옮겨 붙인다(282-2). * * 선택 흐름: - * [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체. + * [직선화] → 측점 2개를 그래프에서 고름(클릭 = 늘 측점 · 271-1) → 두 측점의 라운드에 탄젠트한 직선으로 대체. + * 고른 범위가 이미 직선이면 그 범위를 부분 구간으로 고름 → [▲][▼]로 꺾으면 가운데에 + * 라운드 · 양 끝에 기본 L 곡선. Esc = 시작(또는 부분 구간)만 취소 · 모드 유지. * [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림. - * 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에 - * 라운드가 생기고 양쪽이 탄젠트 직선이 된다. * [자유지정] → 범위를 고르고 클릭으로 계획선을 잇는다(`design_profile_free` 몫 · 215-1). * ========================================================================== */ @@ -55,8 +55,12 @@ export interface ProfileTools { handleStationPick: (chainageM: number) => boolean; /** 그래프에서 직선을 눌렀을 때(구간 판정 결과) — 도구가 삼켰으면 true. */ handleRunPick: (run: StraightRun | null) => boolean; - /** 선택 중인 직선 구간들(강조 표시용). */ + /** [쉬프트]로 고른 직선 구간들(붉은 강조용) — [직선화] 부분 구간은 `subRun`(보라 띠 · 271-1). */ selectedRuns: () => StraightRun[]; + /** [직선화] 중 고른 이미 직선인 부분 구간(▲▼ 꺾기 대상). */ + subRun: () => StraightRun | null; + /** [직선화] 시작 측점 · 부분 구간만 취소(Esc · 모드 유지) — 취소한 것이 있으면 true. */ + cancelPick: () => boolean; /** 직선화 대기 중 첫 측점(강조 표시용). */ pendingStation: () => number | null; /** 고른 직선을 ▲▼ 버튼과 같은 경로로 옮긴다(방향키가 같이 쓴다) — 대상이 없으면 false. */ @@ -117,7 +121,15 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo return { mode: () => mode, nudge: step, - selectedRuns: () => runs, + selectedRuns: () => (mode === "shift" ? runs : []), + subRun: () => (mode === "straighten" ? (runs[0] ?? null) : null), + cancelPick() { + if (mode !== "straighten" || (pending === null && !runs.length)) return false; + pending = null; + runs = []; + callbacks.onChanged(); + return true; + }, pendingStation: () => pending, mountOverlay: callbacks.mountOverlay, clear() { @@ -129,6 +141,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo if (mode !== "straighten") return false; if (pending === null) { pending = chainageM; + runs = []; callbacks.onChanged(); return true; } @@ -222,7 +235,12 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo } else if (mode === "straighten" && pending !== null) { const hint = document.createElement("em"); hint.className = "b05-route-profile__tool-hint"; - hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`; + hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (Esc 시작 취소)`; + wrap.append(hint); + } else if (mode === "straighten" && runs.length === 1) { + const hint = document.createElement("em"); + hint.className = "b05-route-profile__tool-hint"; + hint.textContent = "이미 직선 — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선"; wrap.append(hint); } else if (mode === "shift" && !runs.length) { const hint = document.createElement("em"); diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 71a35fa80..6860b155a 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -358,6 +358,15 @@ stroke-width: 1.5; } +/* 범위 「시작–끝 · 길이」 글 — [직선화] · [자유지정] 실시간 띠 위(271-2 · 3). */ +.b05-profile-freemark__span { + fill: var(--color-primary); + font-size: var(--text-caption); + paint-order: stroke; + stroke: var(--color-surface); + stroke-width: 3px; +} + /* 지나온 선 · 미리 보기 · 점 = 녹색(367-4) — 범위 띠 · 경계는 보라 그대로. */ .b05-profile-freemark__line { stroke: var(--color-success); diff --git a/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts b/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts index c09e28a0e..cb55e1265 100644 --- a/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts +++ b/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts @@ -18,6 +18,8 @@ import type { AlignmentCurve, AlignmentPolicy } from "./B05_Profile_UI_Profile_A export const DEFAULT_CURVE_LENGTH_M = 20; /** 초기값 직선 문턱 R(m) — config `initial_straight_radius_m` 짝. */ export const INITIAL_STRAIGHT_RADIUS_M = 500; +/** 법정 미달 판정 오차(m) — R = L/Δ 를 반올림 높이로 되셈한 L 19.99998 거짓 미달 막음(271-1) · 파이썬 짝. */ +export const SHORTFALL_TOLERANCE_M = 1e-3; /** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것 — 파이썬 `flat_bends` · 시종점 false. */ export function flatBends( @@ -72,7 +74,10 @@ export function verticalCurveShortfalls( if (minR <= 0 && minL <= 0) return []; return curves .filter((curve) => !curve.omitted && !curve.skip_allowed) - .filter((curve) => curve.r_m < minR - 1e-6 || curve.l_m < minL - 1e-6) + .filter( + (curve) => + curve.r_m < minR - SHORTFALL_TOLERANCE_M || curve.l_m < minL - SHORTFALL_TOLERANCE_M, + ) .map( (curve) => `${curve.chainage_m.toFixed(1)}m: 종단곡선 R ${curve.r_m.toFixed(0)}m · L ${curve.l_m.toFixed(1)}m — ` + diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts index 843024404..14500970c 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts @@ -109,6 +109,24 @@ export function hasStraightRuns(alignment: ProfileAlignment): boolean { return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index)); } +/** + * 고른 범위가 이미 한 직선이면 그 범위를 부분 구간으로 돌려준다(271-1) — [직선화] ▲▼ 로 꺾으면 + * 양 끝에 기본 L 곡선. 사이 꼭짓점이 없거나 하나라도 꺾였으면 null(보통 직선화). + */ +export function straightSubRun( + alignment: ProfileAlignment, + fromChainageM: number, + toChainageM: number, +): StraightRun | null { + const fromM = Math.min(fromChainageM, toChainageM); + const toM = Math.max(fromChainageM, toChainageM); + const inside = alignment.pvi + .map((node, index) => ({ at: node.chainage_m, index })) + .filter(({ at }) => at > fromM + SAME_STATION_M && at < toM - SAME_STATION_M); + if (!inside.length || !inside.every(({ index }) => straightAt(alignment.pvi, index))) return null; + return { fromM, toM, nodes: [fromM, ...inside.map(({ at }) => at), toM] }; +} + /** 두 측점 사이(끝 제외)의 변화점 chainage. */ function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] { return pviChainages(alignment).filter( @@ -131,17 +149,33 @@ function withOffsets( return { station_offsets: stationOffsets, curve_radii: curveRadii }; } +/** + * 범위 끝 R 이 저장 자동 R 과 같으면 지운다 — 끝 곡선 = 기본 L(271-1 · ▲▼ 433-13 과 같은 규칙) · + * 사용자 R 은 둠. `radii` 를 고쳐 쓴다. + */ +export function dropAutoEndRadii( + radii: Record, + ends: number[], + autoRadii: Readonly>, +): void { + ends.map(chainageKey).forEach((key) => { + if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key]; + }); +} + /** * 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다. * * 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의 * 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다). + * 끝 R 이 저장 자동 R(`autoRadii`)이면 지워 기본 L(271-1). */ export function straightenBetween( base: AlignmentBase, edits: AlignmentEdits, fromChainageM: number, toChainageM: number, + autoRadii: Readonly> = {}, ): AlignmentEdits { const fromM = Math.min(fromChainageM, toChainageM); const toM = Math.max(fromChainageM, toChainageM); @@ -156,11 +190,13 @@ export function straightenBetween( const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span; return [chainageKey(chainage), target - baseElevationAt(base, chainage)]; }); - return withOffsets( + const next = withOffsets( edits, [...updates, ...pinEnds(base, current, [fromM, toM])], inner.map(chainageKey), ); + dropAutoEndRadii(next.curve_radii, [fromM, toM], autoRadii); + return next; } /** @@ -245,6 +281,8 @@ export function tiltStraightRun( if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ); else place(chainage, pivot, pivotZ, run.toM, endZ); }); + // 부분 구간(271-1) 끝이 꼭짓점 아닌 측점이면 지금 높이로 세움 — 끝이 따라 움직이지 않게 + updates.push(...pinEnds(base, current, [run.fromM, run.toM])); const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12); const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12); diff --git a/resources/tester/helper_271_straighten_pick.cjs b/resources/tester/helper_271_straighten_pick.cjs new file mode 100644 index 000000000..f2e0572a5 --- /dev/null +++ b/resources/tester/helper_271_straighten_pick.cjs @@ -0,0 +1,306 @@ +/* 271-1~3 종단 직선화 측점 고름 · 끝 R 기본 L · 실시간 띠 단위검증 헬퍼 — `_Straighten.ts` · + * `_Tools.ts` · `design_profile_free.ts` · `design_profile_range_band.ts` 를 그 자리에서 트랜스파일해 + * 돌리고 파이썬 거울 입력(base · edits · TS 곡선)을 JSON 으로 낸다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +// 화면 틀 파일(@ui 별칭)은 Node 가 못 읽음 — 도구줄 그림만 쓰므로 빈 그림으로 갈음 +const STUBS = { "./B05_Profile_UI_RouteEdit_Chrome": { HISTORY_ICON: { undo: "", redo: "" } } }; +const cache = new Map(); +function loadTs(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 near = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +const LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts")); +const st = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts")); +const law = loadTs(path.join(LOGIC, "B05_Profile_Profile_CurveLaw.ts")); +const tools = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Tools.ts")); +const DESIGN = path.join(ROOT, "A00_Common", "design"); +const free = loadTs(path.join(DESIGN, "logic", "design_profile_free.ts")); +const band = loadTs(path.join(DESIGN, "design_profile_range_band.ts")); + +const policy = { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 20, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, + min_vertical_radius_m: 100, + min_curve_length_m: 20, +}; +// 0~300 m · 20 m 측점 — 마디마다 기울기가 다른 전체 측점 폴리라인(서버와 같이 지정 없는 꺾임 = 기본 L) +const S = Array.from({ length: 16 }, (_, i) => i * 20); +const g = (s) => 100 + 0.03 * s + 0.6 * Math.sin(s / 13); +const base = { + policy, + onlyExplicitCurves: false, + basePvi: S.map((s) => ({ chainage_m: s, elevation_m: +g(s).toFixed(6) })), + chainage: S, + ground: S.map((s) => +g(s).toFixed(6)), + stations: S.map((s) => ({ station_id: `S${s}`, chainage_m: s })), +}; +const tidy = (e) => st.tidyEdits(base, e); +const build = (e) => align.buildAlignment(base, e); +const curves = (e) => build(e).curves.map((c) => ({ at: c.chainage_m, l: c.l_m, r: c.r_m })); +const shortfalls = (e) => build(e).warnings.filter((w) => w.includes("미달")); + +const out = {}; +const mirror = []; +const keep = (name, edits) => { + mirror.push({ name, edits, curves: curves(edits).map((c) => [c.at, c.r, c.l]) }); + return edits; +}; + +// ① 직선화 40~200 — 사이 곡선 없음 · 양 끝 기본 L 20 +const e1 = keep("straight", tidy(st.straightenBetween(base, align.emptyEdits(), 40, 200))); +const a1 = build(e1); +out.straight = { curves: curves(e1), radii: Object.keys(e1.curve_radii) }; + +// ② 이미 직선인 범위 = 부분 구간 · 반대 방향 같음 · 꺾인 · 섞인 범위 = null +out.sub = st.straightSubRun(a1, 80, 160); +out.subReverse = st.straightSubRun(a1, 160, 80); +out.subBent = st.straightSubRun(build(align.emptyEdits()), 40, 200); +out.subMixed = st.straightSubRun(a1, 100, 260); +out.subOneSpan = st.straightSubRun(a1, 80, 100); + +// ③ 부분 구간 ▲(틸트) = 가운데 + 양 끝 L 20 · 거짓 미달 없음 +const e3 = keep("tiltSub", tidy(st.tiltStraightRun(base, e1, out.sub, 0.5))); +out.tiltSub = { curves: curves(e3), shortfalls: shortfalls(e3) }; +// 판정 오차 짝(1e-3) — 옛 1e-6 이면 L 19.99998 이 미달로 읽힘 +out.tolerance = law.SHORTFALL_TOLERANCE_M; +out.nearMiss = law.verticalCurveShortfalls( + [{ chainage_m: 120, l_m: 19.99998, r_m: 800, omitted: false, skip_allowed: false }], + policy, +); +out.realMiss = law.verticalCurveShortfalls( + [{ chainage_m: 120, l_m: 19.9, r_m: 800, omitted: false, skip_allowed: false }], + policy, +); + +// ④ 끝에 저장 자동 R → 지움(기본 L 20) · 사용자 R 은 둠 +const saved = { "40.000": 5330, "200.000": 1084 }; +const savedEdits = { station_offsets: {}, curve_radii: { ...saved } }; +const e4 = keep("autoEnds", tidy(st.straightenBetween(base, savedEdits, 40, 200, saved))); +out.autoEnds = { radii: Object.keys(e4.curve_radii), curves: curves(e4) }; +const userEdits = { station_offsets: {}, curve_radii: { "40.000": 600, "200.000": 1084 } }; +const e4u = tidy(st.straightenBetween(base, userEdits, 40, 200, saved)); +out.userEnd = { radii: e4u.curve_radii }; +// 자동 R 기준 없이(옛 부름) = 그대로 남음 +out.noAuto = Object.keys(tidy(st.straightenBetween(base, savedEdits, 40, 200)).curve_radii); + +// ⑤ BP · EP — 시종점 곡선 없음 +const e5 = keep("bpEp", tidy(st.straightenBetween(base, align.emptyEdits(), 0, 300))); +out.bpEp = curves(e5); +const eBP = tidy(st.straightenBetween(base, align.emptyEdits(), 0, 200)); +const subBP = st.straightSubRun(build(eBP), 0, 80); +const e5t = keep("tiltBp", tidy(st.tiltStraightRun(base, eBP, subBP, 0.5))); +out.tiltBp = curves(e5t).map((c) => c.at); + +// ⑥ 곡선 섞인 범위 = 안쪽 R 지움 · 양 끝 L 20 +const mixedIn = { station_offsets: {}, curve_radii: { "120.000": 900, "140.000": 700 } }; +const e6 = keep("mixed", tidy(st.straightenBetween(base, mixedIn, 100, 260, {}))); +out.mixed = { radii: Object.keys(e6.curve_radii), curves: curves(e6) }; + +// ⑦ 자유지정 끝 자동 R 지움 · 사용자 R 둠 +const z = (s) => align.controlElevationAt(build(savedEdits), s); +const vertices = [ + { chainage_m: 40, elevation_m: z(40) }, + { chainage_m: 120, elevation_m: z(120) + 1 }, + { chainage_m: 200, elevation_m: z(200) }, +]; +const e7 = keep("freeAuto", tidy(free.buildFreeEdits(base, savedEdits, vertices, saved))); +out.freeAuto = Object.keys(e7.curve_radii); +out.freeUser = Object.keys(tidy(free.buildFreeEdits(base, userEdits, vertices, saved)).curve_radii); + +// ⑧ 자유지정 range 단계 실시간 띠 · Esc = 시작만 취소 +{ + const stations = S; + let edits = align.emptyEdits(); + const log = { closed: 0 }; + const draw = free.createFreeDraw({ + base: () => base, + alignment: () => build(edits), + edits: () => edits, + apply: (next) => (edits = next), + close: () => (log.closed += 1), + changed: () => undefined, + }); + draw.click(41, 0, stations); + draw.hover({ chainageM: 117, elevationM: 0, stations }); + out.liveBand = draw.marks().band; + out.liveEdges = draw.marks().edges; + draw.hover({ chainageM: 13, elevationM: 0, stations }); + out.liveReverse = draw.marks().band; + draw.hover(null); + out.outside = { band: draw.marks().band, edges: draw.marks().edges }; + out.hintPending = draw.hint(); + out.escTaken = draw.key("Escape"); + out.afterEsc = { phase: draw.phase(), edges: draw.marks().edges, closed: log.closed }; + draw.click(41, 0, stations); + draw.click(158, 0, stations); + draw.hover({ chainageM: 260, elevationM: 0, stations }); + out.fixedBand = draw.marks().band; + out.armedPhase = draw.phase(); + out.escArmed = draw.key("Escape"); + out.afterEscArmed = { phase: draw.phase(), closed: log.closed }; +} + +// ⑩ 범위 띠 그림 — 가짜 DOM +class El { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.parent = null; + this.textContent = ""; + this.listeners = {}; + this.isConnected = true; + this.classList = { toggle: () => undefined, add: () => undefined }; + } + setAttribute(name, value) { + this.attrs[name] = value; + } + append(...items) { + items.forEach((item) => { + item.parent = this; + this.children.push(item); + }); + } + remove() { + if (!this.parent) return; + this.parent.children = this.parent.children.filter((c) => c !== this); + this.parent = null; + } + addEventListener(type, fn, opts) { + (this.listeners[type] ??= []).push(fn); + opts?.signal?.addEventListener("abort", () => { + this.listeners[type] = this.listeners[type].filter((f) => f !== fn); + }); + } + fire(type, event = {}) { + (this.listeners[type] ?? []).forEach((fn) => fn(event)); + } + getBoundingClientRect() { + return { left: 0, top: 0 }; + } +} +global.document = { + createElementNS: (_ns, tag) => new El(tag), + createElement: (tag) => new El(tag), +}; +const shape = (svg) => + svg.children.map((c) => + c.tag === "text" + ? `text:${c.textContent}` + : c.tag === "rect" + ? `rect:${c.attrs.x}+${c.attrs.width}` + : `line:${c.attrs.x1}`, + ); +{ + const x = (m) => m * 2; + const svg = new El("svg"); + band.appendRangeBand(svg, x, 100, 120, 40); + out.bandShape = shape(svg); + const one = new El("svg"); + band.appendRangeBand(one, x, 100, 40, null); + out.bandStart = shape(one); + + const chart = new El("div"); + const scroller = new El("div"); + const geom = { + chart, + scroller, + x, + toChainage: (px) => px / 2, + widthPx: 640, + heightPx: 100, + stations: S, + }; + let start = null; + const view = band.createRangeBandView((hover) => (start === null ? null : [start, hover])); + view.mount(geom, true); + out.viewIdle = chart.children.length; + start = 40; + view.redraw(); + out.viewStart = shape(chart.children[0]); + chart.fire("mousemove", { clientX: 2 * 117 }); + out.viewHover = shape(chart.children[0]); + chart.fire("mouseleave"); + out.viewLeave = shape(chart.children[0]); + chart.fire("mousemove", { clientX: 2 * 61 }); + view.mount(geom, true); // 다시 그림(줌 · 스크롤 · 계획선 바뀜) 뒤에도 커서 자리 이어짐 + out.viewRemount = shape(chart.children[0]); + view.mount(geom, false); + out.viewOff = chart.children.length; +} + +// ⑨ 도구 상태 — [직선화] 부분 구간은 붉은 강조(selectedRuns) 아님 · Esc 취소 · 모드 유지 +{ + const picked = []; + const t = tools.createProfileTools({ + onStraighten: (from, to) => picked.push([from, to]), + onShift: () => undefined, + onTilt: (run, delta) => picked.push(["tilt", run.fromM, run.toM, delta]), + onResetToSaved: () => undefined, + canResetToSaved: () => false, + onUndo: () => undefined, + onRedo: () => undefined, + canUndo: () => false, + canRedo: () => false, + hasStraightRuns: () => true, + onChanged: () => undefined, + onModeChange: () => undefined, + freeHint: () => "", + mountOverlay: () => undefined, + }); + const press = (label) => { + const button = t.render().children.find((c) => c.textContent === label); + button.fire("click", { stopPropagation: () => undefined }); + }; + const hintOf = () => t.render().children.find((c) => c.tag === "em")?.textContent ?? null; + out.toolsNone = t.handleStationPick(40); + press("직선화"); + t.handleStationPick(40); + out.pending = { at: t.pendingStation(), hint: hintOf() }; + out.cancel = { taken: t.cancelPick(), pending: t.pendingStation(), mode: t.mode() }; + out.cancelAgain = t.cancelPick(); + t.handleRunPick({ fromM: 80, toM: 160, nodes: [80, 100, 120, 140, 160] }); + out.subPicked = { sub: t.subRun(), red: t.selectedRuns(), hint: hintOf() }; + press("▲"); + out.tiltCall = picked.at(-1); + t.handleStationPick(60); // 새 시작 = 부분 구간 풂 + out.newStart = { pending: t.pendingStation(), sub: t.subRun() }; + t.handleStationPick(180); + out.straightenCall = picked.at(-1); + press("직선화"); // 끔 + press("쉬프트"); + t.handleRunPick({ fromM: 40, toM: 200, nodes: [40, 200] }); + out.shiftRed = { red: t.selectedRuns().length, sub: t.subRun(), cancel: t.cancelPick() }; +} + +out.mirror = { base, cases: mirror }; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_271_straighten_pick.py b/resources/tester/test_271_straighten_pick.py new file mode 100644 index 000000000..fc75e60e5 --- /dev/null +++ b/resources/tester/test_271_straighten_pick.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +"""271-1~3 B05 종단 [직선화] 측점 고름 · 끝 R 기본 L · [직선화] · [자유지정] 실시간 띠. + +요구 271 · PLAN 461. + +계산(`logic/B05_Profile_UI_Profile_Straighten.ts` · `_Tools.ts` · `design_profile_free.ts` · +`design_profile_range_band.ts` · `_CurveLaw.ts`)은 Node 헬퍼(`helper_271_straighten_pick.cjs`)가 +트랜스파일해 돌리고, 파이썬 짝(`build_alignment` · `vertical_curve_shortfalls`)은 같은 base · 편집으로 +곡선 자리 · R · L · 미달 경고가 같은지 거울로 본다. +./venv/Scripts/python.exe -m pytest resources/tester/test_271_straighten_pick.py -q +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + AlignmentPolicy, + build_alignment, +) +from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import ( # noqa: E402 + SHORTFALL_TOLERANCE_M, + vertical_curve_shortfalls, +) + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_271_straighten_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _at(curves, chainage): + return next((c for c in curves if abs(c["at"] - chainage) < 1e-6), None) + + +def test_직선화_끝_기본_L_사이_곡선_없음(out): + curves = out["straight"]["curves"] + assert not [c for c in curves if 40 < c["at"] < 200] + assert _at(curves, 40)["l"] == pytest.approx(20) and _at(curves, 200)["l"] == pytest.approx(20) + assert out["straight"]["radii"] == [] + + +def test_이미_직선_범위_부분_구간_반대_방향_같음(out): + assert out["sub"] == {"fromM": 80, "toM": 160, "nodes": [80, 100, 120, 140, 160]} + assert out["subReverse"] == out["sub"] + assert out["subBent"] is None and out["subMixed"] is None + assert out["subOneSpan"] is None # 사이 꼭짓점 없음 = 꺾을 곳 없음 + + +def test_부분_구간_꺾기_양_끝_L20_거짓_미달_없음(out): + curves = out["tiltSub"]["curves"] + for at in (80, 120, 160): + assert _at(curves, at)["l"] == pytest.approx(20, abs=1e-3) + assert not [c for c in curves if 80 < c["at"] < 160 and c["at"] != 120] + assert out["tiltSub"]["shortfalls"] == [] + + +def test_법정_판정_오차_짝(out): + assert out["tolerance"] == SHORTFALL_TOLERANCE_M == 1e-3 + assert out["nearMiss"] == [] + assert len(out["realMiss"]) == 1 + near = [{"chainage_m": 120, "l_m": 19.99998, "r_m": 800}] + assert vertical_curve_shortfalls(near, 100, 20) == [] + real = [{"chainage_m": 120, "l_m": 19.9, "r_m": 800}] + assert vertical_curve_shortfalls(real, 100, 20) == out["realMiss"] + + +def test_끝_저장_자동_R_지움_사용자_R_둠(out): + assert out["autoEnds"]["radii"] == [] + curves = out["autoEnds"]["curves"] + assert _at(curves, 40)["l"] == pytest.approx(20) and _at(curves, 200)["l"] == pytest.approx(20) + assert out["userEnd"]["radii"] == {"40.000": 600} + assert out["noAuto"] == ["40.000", "200.000"] + + +def test_BP_EP_곡선_없음(out): + assert out["bpEp"] == [] + assert out["tiltBp"][:2] == [40, 80] # 시점 0 엔 곡선 없음 · 가운데 · 끝만 + + +def test_곡선_섞인_범위_안쪽_R_지움_양_끝_L20(out): + assert out["mixed"]["radii"] == [] + curves = out["mixed"]["curves"] + assert not [c for c in curves if 100 < c["at"] < 260] + assert _at(curves, 100)["l"] == pytest.approx(20) and _at(curves, 260)["l"] == pytest.approx(20) + + +def test_자유지정_끝_자동_R_지움_사용자_R_둠(out): + assert out["freeAuto"] == ["120.000"] + assert out["freeUser"] == ["40.000", "120.000"] + + +def test_자유지정_범위_실시간_띠_Esc_시작만(out): + assert out["liveBand"] == [40, 120] and out["liveEdges"] == [40, 120] + assert out["liveReverse"] == [20, 40] + assert out["outside"] == {"band": None, "edges": [40]} # 그래프 밖 = 시작선만 + assert "Esc 시작 취소" in out["hintPending"] + assert out["escTaken"] and out["afterEsc"] == {"phase": "range", "edges": [], "closed": 0} + assert out["fixedBand"] == [40, 160] and out["armedPhase"] == "armed" # 둘째 클릭 뒤 고정 + assert out["escArmed"] and out["afterEscArmed"] == {"phase": "range", "closed": 1} + + +def test_범위_띠_그림(out): + assert out["bandShape"] == ["rect:80+160", "text:40.0–120.0m · 80.0m", "line:80", "line:240"] + assert out["bandStart"] == ["line:80"] + assert out["viewIdle"] == 0 and out["viewOff"] == 0 + assert out["viewStart"] == ["line:80"] + assert out["viewHover"] == out["bandShape"] + assert out["viewLeave"] == ["line:80"] + assert out["viewRemount"] == ["rect:80+40", "text:40.0–60.0m · 20.0m", "line:80", "line:120"] + + +def test_도구_직선화_부분_구간_붉은_강조_아님(out): + assert out["toolsNone"] is False + assert out["pending"] == { + "at": 40, + "hint": "40.0m 선택 — 두 번째 측점을 고르세요 (Esc 시작 취소)", + } + assert out["cancel"] == {"taken": True, "pending": None, "mode": "straighten"} + assert out["cancelAgain"] is False + assert out["subPicked"]["red"] == [] and out["subPicked"]["sub"]["fromM"] == 80 + assert out["subPicked"]["hint"] == "이미 직선 — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선" + assert out["tiltCall"] == ["tilt", 80, 160, 0.1] + assert out["newStart"] == {"pending": 60, "sub": None} + assert out["straightenCall"] == [60, 180] + assert out["shiftRed"] == {"red": 1, "sub": None, "cancel": False} # [쉬프트] 붉은 강조 그대로 + + +def test_파이썬_거울_곡선_자리_R_L_미달(out): + base = out["mirror"]["base"] + policy = AlignmentPolicy.from_dict(base["policy"]) + for case in out["mirror"]["cases"]: + got = build_alignment( + base_s=np.array([n["chainage_m"] for n in base["basePvi"]]), + base_z=np.array([n["elevation_m"] for n in base["basePvi"]]), + chainage=np.array(base["chainage"]), + ground=np.array(base["ground"]), + stations=base["stations"], + policy=policy, + edits=case["edits"], + ) + py = [[c["chainage_m"], c["r_m"], c["l_m"]] for c in got["curves"]] + ts = case["curves"] + assert [c[0] for c in py] == pytest.approx([c[0] for c in ts], abs=1e-6), case["name"] + assert [c[1] for c in py] == pytest.approx([c[1] for c in ts], rel=1e-6), case["name"] + assert [c[2] for c in py] == pytest.approx([c[2] for c in ts], abs=1e-6), case["name"] + assert not [w for w in got["warnings"] if "미달" in w], case["name"] + + +def test_배선(): + panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") + assert "tools.handleStationPick(station ?? chainageM)" in panel + assert "straightSubRun(alignment, fromM, toM)" in panel + assert "straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())" in panel + assert "autoRadii: ctx.savedRadii" in panel + assert 'window.addEventListener("keydown", onEscape, true)' in panel + assert "createRangeBandView(" in panel + view = _read("A00_Common/design/design_profile_free_view.ts") + assert "appendRangeBand(svg, x, heightPx" in view + css = _read("B05_Profile/B05_Profile_UI_Style_Table.css") + rule = css[css.index(".b05-profile-freemark__span {") :] + rule = rule[: rule.index("}")] + assert "--color-primary" in rule and "--color-surface" in rule # 밝음 · 어두움 토큰 + + +def test_줄_수(): + for relative in ( + "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts", + "B05_Profile/B05_Profile_UI_Profile_Tools.ts", + "B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts", + "A00_Common/design/logic/design_profile_free.ts", + "A00_Common/design/design_profile_free_view.ts", + "A00_Common/design/design_profile_range_band.ts", + ): + assert len(_read(relative).splitlines()) <= 700 From 22bf1e98229cecc759094bc76357090234922487 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 22:50:43 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(459=20=EB=85=B8=EC=84=A0=20=EB=B0=94?= =?UTF-8?q?=EB=80=9C):=20[=EC=A0=80=EC=9E=A5]=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=B4=88=EC=95=88=EC=9D=84=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=EA=B0=90=20=EC=9C=84=EC=97=90=20=C2=B7=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=B8=A1=EC=A0=90=20=EC=B4=88=EC=95=88=20?= =?UTF-8?q?=EB=8F=99=EC=95=88=20=EC=A2=8C=ED=91=9C=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EA=B0=90=20=C2=B7=20=EC=98=AE=EA=B8=B4=20=EC=8B=9C=EC=84=A4=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20427=20=EA=BC=AD=EC=A7=93=EC=A0=90=20?= =?UTF-8?q?=EC=84=B8=EC=9B=80=20=C2=B7=20=EA=B4=80=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EA=B0=90=20=EC=84=A0=20=3D=20points?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물: 노선 저장 뒤 rebaseStructureDraft(관 rebasePipeDraft 짝) — 따라가기만 한 것 = 서버 자리 · 사용자가 옮긴 것 = 초안 - 추가 측점: follow 단계가 좌표로 옮긴 정수 m 를 patch.plusStations 로(FOLLOW_KEYS · stations 단계 입력값) · followPlusDraft(가게 쓰기 · 초안 버림 되돌림 · page 부름은 sub7) · follow 만 쓴 초안은 [저장]이 PUT 안 함 - 427: 초안 planline · 서버 _carry_profile 이 바뀐 구간 관 · 구조물 측점(+ 초안 중 사용자가 옮긴 것)에 seatBornNodes 로 다시 세움(route_moved_stations · seat_route_moved 짝) - 서버 관 따라감 = 구조물과 같은 points(사용 범위 자름) · 좌표 없는 옛 관은 옛 노선 자리로 먼저 · 따라감 실패 = follow_failed 알림 - 시험 test_459_route_seat_mirror(거울 · 임시 storage · _place_pipes) · 헬퍼 400 · 449 · design_store_save 가짜 창구 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_save.ts | 39 ++- A00_Common/design/logic/design_pipeline.ts | 7 +- .../design/logic/design_pipeline_stations.ts | 7 +- .../design/logic/design_pipeline_types.ts | 2 + B05_Profile/B05_Profile_Api_PlusStations.ts | 37 ++- B05_Profile/B05_Profile_Api_Replan.ts | 2 + B05_Profile/B05_Profile_Route_Save.py | 30 +- B05_Profile/B05_Profile_Route_Save_Parts.py | 39 +++ .../logic/B05_Profile_Pipeline_Grade.ts | 115 ++++++- .../logic/B05_Profile_Sections_Stations.ts | 10 +- common_util/common_util_route_follow.ts | 62 ++++ resources/tester/helper_400_profile_carry.cjs | 8 + resources/tester/helper_449_save_pipes.cjs | 1 + resources/tester/helper_459_route_seat.cjs | 85 +++++ resources/tester/helper_design_store_save.cjs | 8 +- .../tester/test_459_route_seat_mirror.py | 310 ++++++++++++++++++ 16 files changed, 743 insertions(+), 19 deletions(-) create mode 100644 resources/tester/helper_459_route_seat.cjs create mode 100644 resources/tester/test_459_route_seat_mirror.py diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 68f65b9c5..0a0b812ca 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -29,8 +29,13 @@ import { writePendingPipes, } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { rebasePipeDraft } from "../../common_util/common_util_route_follow"; -import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { rebasePipeDraft, rebaseStructureDraft } from "../../common_util/common_util_route_follow"; +import { + fetchStructures, + flushPendingStructures, + readPendingStructures, + writePendingStructures, +} from "../../B05_Profile/B05_Profile_Api_Structures"; import { flushPlusStations } from "../../B05_Profile/B05_Profile_Api_PlusStations"; import { flushUphillOverrides, @@ -209,6 +214,25 @@ async function rebasePendingPipes(projectId: string, before: SavedPipes): Promis writePendingPipes(projectId, rebasePipeDraft(draft, before, moved)); } +type SavedStructures = Awaited>["structures"]; + +/** 세션 구조물 초안이 있으면 노선 저장 전 정본 — 없으면 · 못 받으면 null(얹기 안 함 · 459). */ +const savedStructuresIfDraft = (projectId: string): Promise => + readPendingStructures(projectId) + ? fetchStructures(projectId) + .then((list) => list.structures) + .catch(() => null) + : Promise.resolve(null); + +/** 노선을 저장함 — 서버가 구조물을 좌표로 따라 옮김. 세션 구조물 초안을 그 위에 얹음(459 · 관 짝) — 구조물 + * 차례가 서버 따라감을 브라우저 누가거리로 덮지 않게. 못 받으면 초안 그대로. */ +async function rebasePendingStructures(projectId: string, before: SavedStructures): Promise { + const draft = readPendingStructures(projectId); + const after = await fetchStructures(projectId).catch(() => null); + if (!draft || !after) return; + writePendingStructures(projectId, rebaseStructureDraft(draft, before, after.structures)); +} + /** * [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다. * 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true. @@ -234,10 +258,19 @@ export async function saveDesign( if (step === "route") { const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄 if (!body) continue; - const before = await savedPipesIfDraft(ctx.projectId); + const [before, beforeStructures] = await Promise.all([ + savedPipesIfDraft(ctx.projectId), + savedStructuresIfDraft(ctx.projectId), + ]); routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림) if (routeSaved.route_id != null) routeId = routeSaved.route_id; if (before) await rebasePendingPipes(ctx.projectId, before); + if (beforeStructures) await rebasePendingStructures(ctx.projectId, beforeStructures); + if (routeSaved.follow_failed?.length) + showToast( + `${routeSaved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`, + "error", + ); continue; } if (step === "cross") { diff --git a/A00_Common/design/logic/design_pipeline.ts b/A00_Common/design/logic/design_pipeline.ts index ce23b5d06..ed7f04fff 100644 --- a/A00_Common/design/logic/design_pipeline.ts +++ b/A00_Common/design/logic/design_pipeline.ts @@ -60,7 +60,12 @@ export const STAGE_DEPS: Record = { }; /** follow 가 고칠 수 있는 입력 — 노선 · 횡단은 안 됨(고리). */ -const FOLLOW_KEYS: ReadonlyArray = ["pipes", "structures", "alignment"]; +const FOLLOW_KEYS: ReadonlyArray = [ + "pipes", + "structures", + "alignment", + "plusStations", +]; /** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단이 그다음. */ const YIELD_AFTER = new Set(["profile"]); export const RUN_LOG_SIZE = 20; diff --git a/A00_Common/design/logic/design_pipeline_stations.ts b/A00_Common/design/logic/design_pipeline_stations.ts index 9c4d19d62..204cc2b15 100644 --- a/A00_Common/design/logic/design_pipeline_stations.ts +++ b/A00_Common/design/logic/design_pipeline_stations.ts @@ -74,7 +74,12 @@ export function createStationsStage( route.curves, (ctx.inputs.pipes as PlanPipe[] | null) ?? [], (ctx.inputs.structures as PlanStructure[] | null) ?? [], - { ...rest, types: options.types(), ...snapOptions(ctx.inputs.route, saved?.()) }, + { + ...rest, + types: options.types(), + ...snapOptions(ctx.inputs.route, saved?.()), + plus: ctx.inputs.plusStations ?? undefined, // follow 가 옮긴 추가 측점(459 · 가게보다 먼저) + }, ); return reuseOutside(next, ctx.prev, ctx.range); }; diff --git a/A00_Common/design/logic/design_pipeline_types.ts b/A00_Common/design/logic/design_pipeline_types.ts index 003289de6..f63d5fc4a 100644 --- a/A00_Common/design/logic/design_pipeline_types.ts +++ b/A00_Common/design/logic/design_pipeline_types.ts @@ -152,6 +152,8 @@ export interface FollowPatch { pipes?: unknown; structures?: unknown; alignment?: AlignmentEdits; + /** 추가 측점 — 새 노선 정수 m(459 · 좌표 따라감). */ + plusStations?: ReadonlyArray | null; } /** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점) · 곡선(MC · 252-2 · 격자 번호 없음). */ diff --git a/B05_Profile/B05_Profile_Api_PlusStations.ts b/B05_Profile/B05_Profile_Api_PlusStations.ts index dcec5bfbd..562ce9897 100644 --- a/B05_Profile/B05_Profile_Api_PlusStations.ts +++ b/B05_Profile/B05_Profile_Api_PlusStations.ts @@ -70,8 +70,43 @@ export function loadSavedPlusStations(projectId: string): Promise { return loaded.get(projectId)!; } -/** [저장] 차례 — 초안이 있으면 정본에 씀 · 없으면 저장분만 다시 받음(노선 저장이 좌표로 옮겼을 수 있음). */ +/** 노선 초안 전 추가 측점 초안(없으면 null) — 초안을 버리면 이것으로(459). */ +const beforeRoute = new Map(); +/** follow 만 쓴 초안(사용자 초안 없던 때) — 그대로면 [저장]이 PUT 안 함(서버 따라감이 정본 · 459). */ +const followedOnly = new Map(); + +/** + * 노선 초안 동안 추가 측점 따라감(459) — 묶음 follow 출력(`plusStations` · 새 노선 정수 m)을 초안에 씀 · + * `next = null` = 노선 초안을 버림(초안 전 값으로). 부르는 곳 = design_pipeline_page follow 구독 + * (관 · 구조물 `writeFacilities` 곁 · 초안 버림 갈래도). + */ +export function followPlusDraft( + projectId: string, + next: ReadonlyArray | null | undefined, +): void { + if (next === null) { + if (!beforeRoute.has(projectId)) return; + writePlusDraft(projectId, beforeRoute.get(projectId)!); + beforeRoute.delete(projectId); + followedOnly.delete(projectId); + return; + } + if (!next) return; + if (!beforeRoute.has(projectId)) beforeRoute.set(projectId, readPlusDraft(projectId)); + writePlusDraft(projectId, [...next]); + if (beforeRoute.get(projectId)) followedOnly.delete(projectId); + else followedOnly.set(projectId, JSON.stringify(readPlusDraft(projectId))); +} + +/** [저장] 차례 — 사용자 초안이 있으면 정본에 씀 · 없으면(follow 만 쓴 초안 포함 · 459) 저장분만 다시 받음 + * (노선 저장이 좌표로 옮겼을 수 있음). */ export async function flushPlusStations(projectId: string): Promise { + const auto = followedOnly.get(projectId); + followedOnly.delete(projectId); + beforeRoute.delete(projectId); + const kept = readPlusDraft(projectId); + if (kept && auto === JSON.stringify(kept)) + useDesignStore(projectId, null).set("plusStations", null); const draft = readPlusDraft(projectId); const list = draft ? await request(projectId, { method: "PUT", body: JSON.stringify({ chainages: draft }) }) diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 95a8a85ec..dde65c0c8 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -135,6 +135,8 @@ export interface RouteReplanResponse { /** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차. */ pipes?: { kept: number; crossing: number }; structures?: { moved: number; far: number }; + /** 따라감 실패한 것(「구조물」 · 「추가 측점」) — 옛 누가거리 그대로(459). */ + follow_failed?: string[]; /** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */ cross_dropped?: number; /** 배수유역 저장분이 새 노선과 안 맞음(다시 안 셈). */ diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index a86e95be7..4799577d6 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -45,6 +45,8 @@ from B05_Profile.B05_Profile_Route_Save_Parts import ( add_crossing_pipes, carry_cross_inputs, follow_pipes, + route_moved_stations, + seat_route_moved, station_keys, vertices_of, ) @@ -168,16 +170,24 @@ async def _place_pipes( old_xy: list[list[float]], points: list[dict[str, float]], ) -> tuple[list, dict[str, int]]: - """관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369).""" + """관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369). + + 따라갈 선 = 구조물 · 추가 측점과 같은 `points`(사용 범위 자름 · 459) — B04 `_prepare` + 노선은 범위를 안 잘라 누가거리가 갈림 · 세류선만 거기서. 좌표 없는 옛 관은 옛 노선 + 자리로 먼저 채움(459). + """ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare - from common_util.common_util_drainage_pipes import route_signature, save_pipe_points + from common_util.common_util_drainage_pipes import ( + fill_pipe_coordinates, + route_signature, + save_pipe_points, + ) prepared = await _prepare(project_id) - if isinstance(prepared, JSONResponse): # 도엽 · 노선 파일 없음 — 따라감만 - vertices, streams = vertices_of([(p["x"], p["y"]) for p in points]), [] - else: - vertices, streams = prepared["vertices"], prepared["streams"] + streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"] # 도엽 없음 + vertices = vertices_of([(p["x"], p["y"]) for p in points]) + fill_pipe_coordinates(old_pipes, vertices_of(old_xy)) pairs = follow_pipes(old_pipes, vertices) total = float(vertices[-1].chainage_m) crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total) @@ -226,6 +236,10 @@ async def _carry_profile( edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half) alignment["policy"] = carry_policy(old_xy, new_xy, old_alignment, alignment) # 옛 L · 열쇠(432) alignment["base_pvi"] = base + if span is not None: # 옮긴 시설 측점에 427 꼭짓점 다시 세움(459 · 초안 planline 짝) + born = route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy) + held = [float(key) for key in (edits.get("station_offsets") or {})] + alignment["base_pvi"] = seat_route_moved(alignment, born, held) await asyncio.to_thread(atomic_write_json, path, longitudinal) _alignment, summary, _dropped = await asyncio.to_thread( _apply_alignment_edits, project_root, relative, edits @@ -387,6 +401,7 @@ async def run_route_save_chain( ) mark_designing(project_root) try: + follow_failed: list[str] = [] # 따라감 실패 = 옛 누가거리 그대로 · 화면이 알림(459) # 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움) try: structures = await asyncio.to_thread( @@ -395,12 +410,14 @@ async def run_route_save_chain( except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명) logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) structures = {"moved": 0, "far": 0} + follow_failed.append("구조물") try: # 추가 측점도 땅 위 자리 따라감(368-2 · 실패 = 옛 누가거리 그대로) await asyncio.to_thread( follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points] ) except Exception: # noqa: BLE001 logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id) + follow_failed.append("추가 측점") request = redesign_solve_request(params, selection, surface_model_id) solved: Any = await solve_route_record(project_id, request, generate_sections=False) if isinstance(solved, JSONResponse): @@ -481,6 +498,7 @@ async def run_route_save_chain( "total_length_m": float(solved.total_length_m or 0.0), "pipes": pipes, "structures": structures, + "follow_failed": follow_failed, "cross_dropped": dropped, "drainage_stale": True, "seconds": round(marks[-1][1] - marks[0][1], 2), diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index e26c9918e..ab33ac882 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -186,3 +186,42 @@ def carry_cross_inputs( if adjust: adjusts[round(new_at, 3)] = adjust return inputs, adjusts, dropped + + +def route_moved_stations( + stations: Sequence[dict[str, Any]], + old_xy: Sequence[Sequence[float]], + new_xy: Sequence[Sequence[float]], +) -> list[float]: + """노선 바뀜으로 옮긴 시설 측점 — 관 · 구조물 측점(`structure` 이름 있음) 중 옛 선과 + 같은 자리가 아닌 것(`line_matcher` 짝 없음 · 459). 같은 자리로 따라간 시설은 뺌(옛 높이 + 그대로). TS 짝 = `routeMovedStations`(B05_Profile/logic/B05_Profile_Pipeline_Grade.ts) · + 거울 시험 = test_459_route_seat_mirror.py.""" + from B05_Profile.B05_Profile_Engine_Grade_Carry import line_matcher + + match = line_matcher([tuple(p) for p in old_xy], [tuple(p) for p in new_xy]) + return sorted( + float(station["chainage_m"]) + for station in stations + if station.get("structure") and match(float(station["chainage_m"])) is None + ) + + +def seat_route_moved( + alignment: dict[str, Any], born: Sequence[float], held: Sequence[float] = () +) -> list[dict[str, Any]]: + """옮긴 시설 측점(`born`)에 427 규칙으로 꼭짓점을 다시 세움(459 · 요구 270) — 그 자리 + 옛 꼭짓점(옮겨 붙은 지반 · 옛 높이)을 걷고 원지반 지남 · 이웃 1 mm 맞춤 · 균형 종단이면 + 옮기기 전 계획선 높이(`_seat_born_nodes`). `alignment` = 새 지반 표본 · 정책 · 옮겨 붙인 + `base_pvi` · `held` = 높이 고친 측점. TS 짝 = `seatRouteMoved`.""" + from B05_Profile.B05_Profile_Router_Confirm import _SAME_STATION_M, _seat_born_nodes + + base = alignment.get("base_pvi") or [] + if not born: + return base + kept = [ + node + for node in base + if all(abs(float(node["chainage_m"]) - value) >= _SAME_STATION_M for value in born) + ] + return _seat_born_nodes(alignment, kept, list(born), list(held)) diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index 07b28bec4..a6a7d8527 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -4,9 +4,12 @@ * `A00_Common/design/logic/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터): * * follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry` - * `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400). + * `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400) · + * 추가 측점 좌표 따라감(459 · `followPlusStations`). * planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점 - * 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`). + * 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 · + * 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts` + * `route_moved_stations` · `seat_route_moved` · 거울 시험 test_459_route_seat_mirror.py). * profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수. * * 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음. @@ -15,24 +18,34 @@ import type { FollowPatch, PipelineInputs, + PipelineStation, RangePart, RouteLine, StageFn, Vertex, } from "../../A00_Common/design/logic/design_pipeline_types"; import { + followPlusStations, followStructures, projectPipePoints, type FollowingPoint, type FollowingStructure, } from "../../common_util/common_util_route_follow"; +import { PLUS_OWNER_PREFIX, SAME_STATION_M } from "../../common_util/common_util_structure_station"; import { buildAlignment, emptyEdits, type AlignmentBase, type AlignmentEdits, } from "./B05_Profile_UI_Profile_Alignment"; -import { carryBase, carryEdits, changedSpan, type XY } from "./B05_Profile_Grade_Carry"; +import { + carryBase, + carryEdits, + changedSpan, + lineMatcher, + type XY, +} from "./B05_Profile_Grade_Carry"; +import { seatBornNodes } from "./B05_Profile_Grade_Seat"; import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations"; import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground"; import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw"; @@ -99,14 +112,17 @@ export function createFollowStage(deps: { }): StageFn<"follow"> { let ref: GradeRef | null = null; let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로 + let moved = new Set(); // 노선 초안 동안 사용자가 옮긴 · 놓은 시설 id(459 · 427 세움) return (ctx) => { + if (!ctx.inputs.route) moved = new Set(); if (!ctx.inputs.route && deps.saved) return deps.saved(); const before = ctx.before.route?.line ?? deps.savedLine(); const now = ctx.out.route; if (!before || !now) return {}; const line = xy(now.line); const patch: FollowPatch = {}; - const { pipes, structures, alignment } = ctx.inputs; + const { pipes, structures, alignment, plusStations } = ctx.inputs; + if (ctx.before.follow) userMoved(ctx.inputs, ctx.before.follow).forEach((id) => moved.add(id)); if (Array.isArray(pipes) && pipes.length) patch.pipes = projectPipePoints(pipes as FollowingPoint[], line); if (Array.isArray(structures) && structures.length) @@ -116,6 +132,10 @@ export function createFollowStage(deps: { undefined, SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3) ).map((moved) => moved.item); + // 추가 측점도 땅 위 자리 따라감(459 · 서버 `follow_plus_stations` 짝) — 가게는 부르는 쪽이 씀 + if (plusStations?.length) + patch.plusStations = followPlusStations(plusStations, xy(before), line); + movedIds.set(patch, new Set(moved)); const planline = ctx.before.planline; if (alignment !== carried && JSON.stringify(alignment) !== JSON.stringify(carried)) ref = @@ -134,6 +154,71 @@ export function createFollowStage(deps: { }; } +/** 같은 꼭짓점 자리(m) — `seatBornNodes` · 파이썬 `_SAME_STATION_M` 과 같은 0.006. */ +const SEAT_SAME_M = 0.006; + +/** follow 출력마다 그때까지 사용자가 옮긴 시설 id — planline 이 427 세움에 씀(459). */ +const movedIds = new WeakMap>(); + +type Facility = { id?: string | null; structure_id?: string | null; chainage_m?: number | null }; + +/** 입력 관 · 구조물 중 앞 follow 출력과 자리가 다른(사용자가 옮김) · 새로 놓은 것의 id. */ +function userMoved( + inputs: Pick, + followed: FollowPatch, +): string[] { + const out: string[] = []; + for (const key of ["pipes", "structures"] as const) { + const now = inputs[key]; + const was = followed[key]; + if (!Array.isArray(now) || !Array.isArray(was)) continue; + const idOf = (item: Facility) => (key === "pipes" ? item.id : item.structure_id) ?? null; + const at = new Map((was as Facility[]).map((item) => [idOf(item), item.chainage_m ?? null])); + for (const item of now as Facility[]) { + const id = idOf(item); + const old = at.get(id); + if (id && (old == null || Math.abs((item.chainage_m ?? 0) - old) > SAME_STATION_M)) + out.push(id); + } + } + return out; +} + +/** 노선 바뀜으로 옮긴 시설 측점(459) — 관 · 구조물 측점 중 옛 선과 같은 자리가 아닌 것(`lineMatcher` 짝 + * 없음) · 사용자가 옮긴 시설(`moved` id). 추가 측점(`plus-`)은 뺌. 파이썬 `route_moved_stations` 짝(그쪽은 + * 사용자가 옮긴 것을 관 · 구조물 저장이 세움). */ +export function routeMovedStations( + stations: ReadonlyArray>, + oldXy: ReadonlyArray, + newXy: ReadonlyArray, + moved: ReadonlySet = new Set(), +): number[] { + const match = lineMatcher(oldXy, newXy); + return stations + .filter( + (station) => + (station.kind === "pipe" || station.kind === "structure") && + !station.id?.startsWith(PLUS_OWNER_PREFIX) && + ((station.id !== null && moved.has(station.id)) || match(station.chainageM) === null), + ) + .map((station) => station.chainageM) + .sort((a, b) => a - b); +} + +/** 옮긴 시설 측점(`born`)에 427 꼭짓점을 다시 세움 — 그 자리 꼭짓점을 걷고 `seatBornNodes`(원지반 지남 · + * 이웃 1 mm 맞춤 · 균형 종단이면 옮기기 전 계획선 높이). 파이썬 `seat_route_moved` 짝. */ +export function seatRouteMoved( + base: Pick, + born: ReadonlyArray, + held: ReadonlyArray = [], +): AlignmentBase["basePvi"] { + if (!born.length) return base.basePvi; + const kept = base.basePvi.filter((node) => + born.every((value) => Math.abs(node.chainage_m - value) >= SEAT_SAME_M), + ); + return seatBornNodes(base, kept, born, held); +} + /** 일반측점 격자 + 끝 — `stationsAt` 없을 때(시험 · 단독 묶음). */ const gridOf = (route: RouteLine): number[] => { const grid: number[] = []; @@ -176,10 +261,30 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> { const savedLine = deps.savedLine(); const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null; const savedPvi = deps.storedPvi?.() ?? saved.basePvi; + const carried = span + ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) + : savedPvi; + // 옮긴 시설 측점에 427 꼭짓점 다시 세움(459 · 서버 `_carry_profile` 짝 · 사용자가 옮긴 것은 즉시) + const followed = ctx.out.follow; + const moved = new Set(followed ? movedIds.get(followed) : []); + if (followed) userMoved(ctx.inputs, followed).forEach((id) => moved.add(id)); + const born = savedLine + ? routeMovedStations(ground.stations, xy(savedLine), xy(route.line), moved) + : []; + const held = Object.keys(ctx.inputs.alignment?.station_offsets ?? {}).map(Number); return { policy: saved.policy, // 옛 기본 L 이어받음(서버 `carry_policy` 짝) onlyExplicitCurves: saved.onlyExplicitCurves, - basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi, + basePvi: seatRouteMoved( + { + chainage: profile.chainage, + ground: profile.ground, + policy: saved.policy, + basePvi: carried, + }, + born, + held, + ), chainage: profile.chainage, ground: profile.ground, stations: ground.stations.map((station) => ({ diff --git a/B05_Profile/logic/B05_Profile_Sections_Stations.ts b/B05_Profile/logic/B05_Profile_Sections_Stations.ts index ff215d4ff..86a438510 100644 --- a/B05_Profile/logic/B05_Profile_Sections_Stations.ts +++ b/B05_Profile/logic/B05_Profile_Sections_Stations.ts @@ -235,6 +235,8 @@ export interface StationsOptions { types: ReadonlyArray; /** 종류 → 옵션 기본값(카드 이름) — `planStations` 그대로. */ registry?: Readonly>>; + /** 추가 측점(정수 m) — 없으면 `planStations` 기본(전역 `plusStations()`). */ + plus?: ReadonlyArray; } /** 원래 격자 번호 — 스냅으로 옮긴 측점은 사라진 원래 격자 자리 중 가장 가까운 번호를 이어받음. */ @@ -299,7 +301,13 @@ export function buildStations( // 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`). const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`)); const planted = new Map(); - for (const station of planStations(pipes, structures, options.types, options.registry)) { + for (const station of planStations( + pipes, + structures, + options.types, + options.registry, + options.plus, + )) { if (station.chainage_m < 0 || station.chainage_m > total) continue; const near = grid.find((value) => Math.abs(value - station.chainage_m) < SNAP_M); const at = near ?? station.chainage_m; diff --git a/common_util/common_util_route_follow.ts b/common_util/common_util_route_follow.ts index 396ecef98..68cc09a62 100644 --- a/common_util/common_util_route_follow.ts +++ b/common_util/common_util_route_follow.ts @@ -13,6 +13,8 @@ * 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py. * - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 * 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u). + * - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝). + * - `followPlusStations` ↔ 파이썬 `B05_Profile_Plus_Stations.follow_plus_stations`(459 · 노선 초안 동안). * 거울 시험 = resources/tester/test_344_2_route_pairs.py. * ========================================================================== */ @@ -142,6 +144,66 @@ export function rebasePipeDraft

a.chainage_m - b.chainage_m); } +type StructureLike = FollowingStructure & { structure_id?: string | null }; + +/** 자리 셋(기준 · 구간 끝)이 같은가 — `shift` 만큼 민 자리로 견줌(구간 길이 = 시설 치수). */ +const samePositions = (a: FollowingStructure, b: FollowingStructure, shift = 0): boolean => + (["chainage_m", "start_m", "end_m"] as const).every( + (key) => + (a[key] == null) === (b[key] == null) && + Math.abs((a[key] ?? 0) + shift - (b[key] ?? 0)) <= SAME_STATION_M, + ); + +/** 노선을 저장한 [저장] — 세션 구조물 초안을 서버가 따라 옮긴 정본(`after`) 위에 얹은 목록(459 · 관 + * `rebasePipeDraft` 짝). 따라가기만 한 것(자리 = 노선 저장 전 정본 `before` 그대로 · 또는 땅 위 자리가 같고 + * 기준 누가거리가 서버 따라감과 같음)은 서버 자리(기준 · 구간 끝 · 좌표) · 사용자가 옮긴 것(마커 · 폼 = + * 누가거리만 옮겨 좌표 낡음 포함) · 새로 놓은 것은 초안 그대로 · 옵션 · 메모는 늘 초안 · 짝은 `structure_id`. */ +export function rebaseStructureDraft( + draft: ReadonlyArray, + before: ReadonlyArray, + after: ReadonlyArray, +): S[] { + const old = new Map(before.map((item) => [item.structure_id, item])); + const moved = new Map(after.map((item) => [item.structure_id, item])); + return draft.map((item) => { + const was = item.structure_id ? old.get(item.structure_id) : undefined; + const now = item.structure_id ? moved.get(item.structure_id) : undefined; + if (!was || !now) return item; + const followed = + item.x != null && + item.y != null && + was.x != null && + was.y != null && + Math.hypot(item.x - was.x, item.y - was.y) <= FOLLOW_SAME_XY_M && + samePositions(was, item, (item.chainage_m ?? 0) - (was.chainage_m ?? 0)) && + Math.abs((item.chainage_m ?? 0) - (now.chainage_m ?? 0)) <= SAME_STATION_M; + if (!followed && !samePositions(item, was)) return item; + return { + ...item, + chainage_m: now.chainage_m ?? item.chainage_m, + start_m: now.start_m ?? null, + end_m: now.end_m ?? null, + x: now.x ?? item.x, + y: now.y ?? item.y, + }; + }); +} + +/** 추가 측점이 노선을 따라감(459) — 옛 선(`from`) 위 누가거리의 땅 위 자리를 새 선(`to`)에 투영한 정수 m + * (같은 m 하나 · 오름차순). 파이썬 `follow_plus_stations` 짝(`follow_structures` 같은 셈). */ +export function followPlusStations( + list: ReadonlyArray, + from: ReadonlyArray, + to: ReadonlyArray, +): number[] { + const placed = placeOnLine( + list.map((chainage_m) => ({ chainage_m })), + from, + ); + const moved = followStructures(placed, to).map(({ item }) => item.chainage_m!); + return [...new Set(moved)].sort((a, b) => a - b); +} + /** 따라가기만 한 관 = 정본과 같은 땅 위 자리(m) — 서버 좌표가 mm 3자리라 그 반올림 폭. */ const FOLLOW_SAME_XY_M = 0.001; diff --git a/resources/tester/helper_400_profile_carry.cjs b/resources/tester/helper_400_profile_carry.cjs index 36d9aff5c..824a8ad56 100644 --- a/resources/tester/helper_400_profile_carry.cjs +++ b/resources/tester/helper_400_profile_carry.cjs @@ -37,9 +37,17 @@ const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", { "./B05_Profile_Profile_CurveLaw": curveLaw, "./B05_Profile_UI_Profile_Alignment": alignment, }); +const seat = loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts", { + "./B05_Profile_UI_Profile_Alignment": alignment, +}); const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", { "./B05_Profile_Profile_CurveLaw": curveLaw, "../../common_util/common_util_route_follow": {}, + "../../common_util/common_util_structure_station": { + PLUS_OWNER_PREFIX: "plus-", + SAME_STATION_M: 0.005, + }, + "./B05_Profile_Grade_Seat": seat, "./B05_Profile_UI_Profile_Alignment": alignment, "./B05_Profile_Grade_Carry": carry, "./B05_Profile_Sections_Stations": { SECTION_STATION_INTERVAL_M: 20 }, diff --git a/resources/tester/helper_449_save_pipes.cjs b/resources/tester/helper_449_save_pipes.cjs index 23380951b..bfcc6ef05 100644 --- a/resources/tester/helper_449_save_pipes.cjs +++ b/resources/tester/helper_449_save_pipes.cjs @@ -71,6 +71,7 @@ STUBS["B04_PreProcess_Api_Fetch"] = { }; STUBS["B05_Profile_Api_Structures"] = { flushPendingStructures: async () => failing("structures"), + readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459) 안 돎 }; STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined }; STUBS["B05_Profile_Api_Fetch"] = { diff --git a/resources/tester/helper_459_route_seat.cjs b/resources/tester/helper_459_route_seat.cjs new file mode 100644 index 000000000..b2df58b77 --- /dev/null +++ b/resources/tester/helper_459_route_seat.cjs @@ -0,0 +1,85 @@ +/* 459 노선 바뀜 — 진짜 TS(`logic/B05_Profile_Pipeline_Grade` 옮긴 시설 측점 · 427 세움 · planline 단계 · + * `common_util_route_follow` 구조물 맞춤 · 추가 측점 따라감)를 Node 로 돌림. + * 입력 argv[2] JSON [{ op, ... }, …] · 표준출력 = 결과 한 벌씩. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +function loadTs(file) { + const full = path.join(ROOT, 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 localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (request.endsWith(".json")) + return JSON.parse(fs.readFileSync(path.join(path.dirname(full), request), "utf8")); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`)); + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts"); +const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts"); +const follow = loadTs("common_util/common_util_route_follow.ts"); +const xy = (line) => line.map(([x, y]) => ({ x, y })); + +const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const out = cases.map((item) => { + switch (item.op) { + case "moved": + return grade.routeMovedStations( + item.stations, + xy(item.oldXy), + xy(item.newXy), + new Set(item.moved ?? []), + ); + case "planline": { + // 계산 묶음 planline 단계 그대로 — 저장 기준(storedPvi) · 새 지반 · 측점 · follow 출력 + const stage = grade.createPlanlineStage({ + savedBase: () => item.base, + savedLine: () => item.oldXy, + storedPvi: () => item.base.basePvi, + }); + const ctxOf = (stations, followed) => ({ + inputs: { route: {}, alignment: null, pipes: item.pipes ?? null, structures: null }, + out: { + route: { line: item.newXy }, + ground: { + stations: stations.map((s) => ({ ...s, ground: null })), + longitudinal: { chainageM: item.chainage, z: item.ground }, + }, + follow: followed, + }, + }); + const result = stage(ctxOf(item.stations, item.followed ?? null)); + // 시설 없는 같은 측점 = 옮겨 붙인 꼭짓점(427 세움 전) — 파이썬 `seat_route_moved` 입력 + const plain = stage( + ctxOf( + item.stations.map((s) => ({ ...s, kind: "grid", id: null })), + null, + ), + ); + return { basePvi: result.basePvi, carried: plain.basePvi }; + } + case "carry": + return carry.carryBase(xy(item.oldXy), xy(item.newXy), item.oldBase, item.groundPvi); + case "rebase": + return follow.rebaseStructureDraft(item.draft, item.before, item.after); + case "plus": + return follow.followPlusStations(item.list, xy(item.from), xy(item.to)); + default: + throw new Error(`op 없음: ${item.op}`); + } +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index b2e350a0b..55a103860 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -59,9 +59,15 @@ const STUBS = { "../../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({ pipe_points: [] }), }, - "../../common_util/common_util_route_follow": { rebasePipeDraft: (draft) => draft }, + "../../common_util/common_util_route_follow": { + rebasePipeDraft: (draft) => draft, + rebaseStructureDraft: (draft) => draft, + }, "../../B05_Profile/B05_Profile_Api_Structures": { flushPendingStructures: () => record("structures", failStructures)(), + readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459)는 안 돎 + writePendingStructures: () => undefined, + fetchStructures: async () => ({ structures: [] }), }, "../../B05_Profile/B05_Profile_Api_PlusStations": { flushPlusStations: () => record("plusStations")(), diff --git a/resources/tester/test_459_route_seat_mirror.py b/resources/tester/test_459_route_seat_mirror.py new file mode 100644 index 000000000..b83807451 --- /dev/null +++ b/resources/tester/test_459_route_seat_mirror.py @@ -0,0 +1,310 @@ +"""459 노선 바뀜(요구 270) — [저장] 구조물 맞춤 · 추가 측점 따라감 · 관 꼭짓점 세움 · 관 따라감 선. + + - 옮긴 시설 측점(바뀐 구간 · 사용자가 옮김)에 427 꼭짓점 다시 세움 — 초안 planline(TS) ↔ + 서버 `_carry_profile`(`route_moved_stations` · `seat_route_moved`) 같은 값 · 같은 자리로 + 따라간 시설은 옛 높이. + - [저장] 구조물 초안 = 서버 따라감 위에(`rebaseStructureDraft` · 관 `rebasePipeDraft` 짝). + - 추가 측점 좌표 따라감 TS `followPlusStations` ↔ 파이썬 `follow_plus_stations`. + - 서버 관 따라감 선 = 구조물과 같은 `points`(사용 범위 자름) · 좌표 없는 옛 관은 옛 노선 + 자리 먼저. +helper_459_route_seat.cjs 가 진짜 TS 를 Node 로 돌림. +""" + +from __future__ import annotations + +import asyncio +import json +import math +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + AlignmentPolicy, + build_curves, + evaluate, +) +from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 + design_ground_following_profile, +) +from B05_Profile.B05_Profile_Plus_Stations import ( # noqa: E402 + follow_plus_stations, + plus_chainages, + save_plus_stations, +) +from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402 + route_moved_stations, + seat_route_moved, +) +from common_util.common_util_drainage_pipes import PipePoint # noqa: E402 + +OLD_XY = [[0.0, 0.0], [200.0, 0.0], [400.0, 0.0]] +NEW_XY = [[0.0, 0.0], [200.0, 0.0], [300.0, 60.0], [420.0, 60.0]] +NEW_LEN = 200.0 + math.hypot(100.0, 60.0) + 120.0 + + +def _old_ground(s: float) -> float: + return 100.0 + 0.08 * s + 1.5 * math.sin(s / 7.0) + + +def _new_ground(s: float) -> float: + """200 m 까지 옛 선과 같은 땅 · 뒤는 다른 땅(노선이 옮겨 감).""" + if s <= 200.0: + return _old_ground(s) + return _old_ground(200.0) + 0.05 * (s - 200.0) + 0.8 * math.cos((s - 200.0) / 9.0) + + +def _old_alignment() -> dict: + samples = [ + {"chainage_m": float(s), "elevation_m": round(_old_ground(s), 6), "valid": True} + for s in range(0, 401) + ] + stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)] + stations.append({"chainage_m": 120.0, "kind": "irregular", "structure": "파형강관 D800"}) + stations.sort(key=lambda row: row["chainage_m"]) + options = GradeDesignOptions( + max_grade_pct=14.0, + max_reverse_grade_pct=5.0, + min_vertical_radius_m=100.0, + min_curve_length_m=20.0, + min_tangent_length_m=20.0, + vertical_curve_skip_delta_pct=0.5, + design_speed_kph=20, + ) + alignment, _ = design_ground_following_profile( + {"samples": samples, "stations": stations}, options, station_interval_m=20.0 + ) + return alignment + + +def _stations(pipe_at: float = 120.0) -> list[dict]: + """새 노선 측점(TS 꼴) — 격자 · 끝 · 관 둘 · 구조물 하나 · 추가 측점 하나.""" + rows = [{"chainageM": float(s), "kind": "grid", "id": None} for s in range(0, 421, 20)] + rows.append({"chainageM": round(NEW_LEN, 3), "kind": "end", "id": None}) + rows += [ + {"chainageM": pipe_at, "kind": "pipe", "id": "p120"}, + {"chainageM": 251.0, "kind": "structure", "id": "s251"}, + {"chainageM": 333.0, "kind": "pipe", "id": "p333"}, + {"chainageM": 77.0, "kind": "structure", "id": "plus-77"}, + ] + return sorted(rows, key=lambda row: row["chainageM"]) + + +def _server_stations(stations: list[dict]) -> list[dict]: + """같은 측점(서버 종단 꼴) — 관 · 구조물 = `structure` 이름 · 추가 측점 = 빈 이름.""" + names = {"pipe": "파형강관 D800", "structure": "옹벽"} + return [ + { + "chainage_m": row["chainageM"], + "kind": "irregular" if row["kind"] in names else "regular", + "structure": "" if (row["id"] or "").startswith("plus-") else names.get(row["kind"]), + } + for row in stations + ] + + +def _ts(tmp_path: Path, cases: list[dict]) -> list: + source = tmp_path / "in459.json" + source.write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_459_route_seat.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _base(alignment: dict) -> dict: + return { + "basePvi": alignment["base_pvi"], + "chainage": [s["chainage_m"] for s in alignment["samples"]], + "ground": [s["ground_elevation_m"] for s in alignment["samples"]], + "policy": alignment["policy"], + "onlyExplicitCurves": False, + "stations": [], + } + + +def _planline_case(alignment: dict, stations: list[dict], **extra) -> dict: + chainage = [float(s) for s in range(0, int(NEW_LEN) + 1)] + return { + "op": "planline", + "base": _base(alignment), + "oldXy": OLD_XY, + "newXy": NEW_XY, + "stations": stations, + "chainage": chainage, + "ground": [round(_new_ground(s), 6) for s in chainage], + **extra, + } + + +def _seat_py(alignment: dict, carried: list[dict], born: list[float]) -> list[dict]: + samples = [ + {"chainage_m": float(s), "ground_elevation_m": round(_new_ground(s), 6)} + for s in range(0, int(NEW_LEN) + 1) + ] + return seat_route_moved( + {"samples": samples, "policy": alignment["policy"], "base_pvi": carried}, born + ) + + +def _plan_at(nodes: list[dict], policy: dict, at: float) -> float: + s = np.array([n["chainage_m"] for n in nodes], dtype=np.float64) + z = np.array([n["elevation_m"] for n in nodes], dtype=np.float64) + curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(policy)) + return float(evaluate(s, z, curves, np.array([at]))[0]) + + +def _same_nodes(mine: list[dict], theirs: list[dict]) -> None: + assert [n["chainage_m"] for n in mine] == pytest.approx([n["chainage_m"] for n in theirs]) + for a, b in zip(mine, theirs, strict=True): + assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6), a["chainage_m"] + + +def test_옮긴_시설_측점_TS_파이썬_같음(tmp_path: Path) -> None: + stations = _stations() + server = route_moved_stations(_server_stations(stations), OLD_XY, NEW_XY) + plain, moved = _ts( + tmp_path, + [ + {"op": "moved", "stations": stations, "oldXy": OLD_XY, "newXy": NEW_XY}, + { + "op": "moved", + "stations": stations, + "oldXy": OLD_XY, + "newXy": NEW_XY, + "moved": ["p120"], + }, + ], + ) + assert server == plain == [251.0, 333.0] # 같은 자리 관 120 · 추가 측점 77 은 뺌 + assert moved == [120.0, 251.0, 333.0] # 사용자가 옮긴 관은 같은 자리여도 세움 + + +def test_초안_planline_은_서버와_같은_꼭짓점(tmp_path: Path) -> None: + alignment = _old_alignment() + [out] = _ts(tmp_path, [_planline_case(alignment, _stations())]) + server = _seat_py(alignment, out["carried"], [251.0, 333.0]) + _same_nodes(out["basePvi"], server) + # 옮긴 시설 측점 계획선 = 새 원지반(옛 계획선이 지반 종단) · 같은 자리 관 120 = 옛 높이 + for at in (251.0, 333.0): + assert _plan_at(out["basePvi"], alignment["policy"], at) == pytest.approx( + _new_ground(at), abs=1e-4 + ) + old = {n["chainage_m"]: n["elevation_m"] for n in out["carried"]} + new = {n["chainage_m"]: n["elevation_m"] for n in out["basePvi"]} + assert new[120.0] == pytest.approx(old[120.0], abs=1e-9) + + +def test_노선_초안_중_마커로_옮긴_관도_즉시_세움(tmp_path: Path) -> None: + alignment = _old_alignment() + case = _planline_case( + alignment, + _stations(pipe_at=125.0), + pipes=[{"id": "p120", "chainage_m": 125.0}], + followed={"pipes": [{"id": "p120", "chainage_m": 120.0}]}, + ) + [out] = _ts(tmp_path, [case]) + server = _seat_py(alignment, out["carried"], [125.0, 251.0, 333.0]) + _same_nodes(out["basePvi"], server) + assert _plan_at(out["basePvi"], alignment["policy"], 125.0) == pytest.approx( + _old_ground(125.0), abs=1e-4 + ) + + +def test_구조물_초안은_서버_따라감_위에(tmp_path: Path) -> None: + before = [ + {"structure_id": "a", "chainage_m": 100.0, "x": 100.0, "y": 0.0}, # 따라가기만(브라우저) + {"structure_id": "b", "chainage_m": 150.0, "x": 150.0, "y": 0.0}, # 안 따라감(옛 판) + {"structure_id": "c", "chainage_m": 180.0, "x": 180.0, "y": 0.0}, # 마커로 옮김(좌표 낡음) + { + "structure_id": "d", + "chainage_m": 300.0, + "start_m": 295.0, + "end_m": 305.0, + "x": 300.0, + "y": 0.0, + }, + ] + after = [ + {"structure_id": "a", "chainage_m": 110.0, "x": 100.0, "y": 0.0}, + {"structure_id": "b", "chainage_m": 160.0, "x": 150.0, "y": 0.0}, + {"structure_id": "c", "chainage_m": 190.0, "x": 180.0, "y": 0.0}, + { + "structure_id": "d", + "chainage_m": 310.0, + "start_m": 305.0, + "end_m": 315.0, + "x": 300.0, + "y": 0.0, + }, + ] + draft = [ + {"structure_id": "a", "chainage_m": 110.0, "x": 100.0, "y": 0.0, "memo": "옵션"}, + {"structure_id": "b", "chainage_m": 150.0, "x": 150.0, "y": 0.0}, + {"structure_id": "c", "chainage_m": 197.0, "x": 180.0, "y": 0.0}, + # 구간 끝만 늘림(따라간 뒤) — 사용자 고침 = 초안 그대로 + { + "structure_id": "d", + "chainage_m": 310.0, + "start_m": 305.0, + "end_m": 320.0, + "x": 300.0, + "y": 0.0, + }, + {"structure_id": "e", "chainage_m": 50.0}, # 새로 놓음 + ] + [out] = _ts(tmp_path, [{"op": "rebase", "draft": draft, "before": before, "after": after}]) + by = {row["structure_id"]: row for row in out} + assert by["a"]["chainage_m"] == 110.0 and by["a"]["memo"] == "옵션" + assert by["b"]["chainage_m"] == 160.0 # 옛 판 = 서버 따라감 + assert by["c"]["chainage_m"] == 197.0 # 사용자가 옮긴 자리 지킴 + assert (by["d"]["start_m"], by["d"]["end_m"]) == (305.0, 320.0) + assert by["e"] == {"structure_id": "e", "chainage_m": 50.0} + + +@pytest.mark.parametrize("chainages", [[30.0, 120.0, 250.0, 390.0], [199.0, 200.0, 201.0]]) +def test_추가_측점_따라감_TS_파이썬_같음(tmp_path: Path, chainages: list[float]) -> None: + save_plus_stations(tmp_path, chainages, OLD_XY) + follow_plus_stations(tmp_path, NEW_XY) + [mine] = _ts(tmp_path, [{"op": "plus", "list": chainages, "from": OLD_XY, "to": NEW_XY}]) + assert mine == plus_chainages(tmp_path) + + +def test_관_따라감_선은_사용_범위_자른_points_좌표_없는_옛_관도(monkeypatch) -> None: + """B04 노선(범위 안 자름 · 0 m 부터) 대신 `points`(50 m 부터) · 좌표 없는 옛 관 = 옛 + 노선 자리로 채운 뒤.""" + import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed + import common_util.common_util_drainage_pipes as drainage + from B05_Profile import B05_Profile_Route_Save as route_save + from B05_Profile.B05_Profile_Route_Save_Parts import vertices_of + + async def prepared(_project_id): + return {"vertices": vertices_of([(0.0, 0.0), (400.0, 0.0)]), "streams": []} + + saved: list = [] + monkeypatch.setattr(watershed, "_prepare", prepared) + monkeypatch.setattr(drainage, "save_pipe_points", lambda *args: saved.append(args[2])) + old = [ + PipePoint(chainage_m=100.0, x=150.0, y=0.0), + PipePoint(chainage_m=200.0), # 좌표 없는 옛 관 + ] + points = [{"x": 40.0, "y": 0.0}, {"x": 350.0, "y": 0.0}] # 시점 10 m 당김 + old_xy = [[50.0, 0.0], [350.0, 0.0]] + pairs, _ = asyncio.run(route_save._place_pipes("p", "s", old, old_xy, points)) + assert [pipe.chainage_m for _, pipe in pairs] == [110.0, 210.0] + assert [pipe.chainage_m for pipe in saved[0]] == [110.0, 210.0]