From d9e8dc41778d414bd8ec587d0150ae738fa125ea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 15:48:26 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(764-1):=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EB=A7=9E=EC=B6=A4=20=EB=8D=9C=20=EB=82=98?= =?UTF-8?q?=EC=81=9C=20=EC=9E=90=EB=A6=AC=EC=97=90=20=EC=9B=90=EC=A7=80?= =?UTF-8?q?=EB=B0=98=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=EB=8A=98=EC=96=B4?= =?UTF-8?q?=EB=82=A8=20=C2=B7=20=EC=98=AE=EA=B8=B4=20=EA=B1=B0=EB=A6=AC=20?= =?UTF-8?q?=EB=94=B0=EC=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요구 606-2 · 분석 761-6 — 설계 지형에서 측점 맞춤이 후보 7/8 을 비탈로 옮겨 추천 셋이 같은 선 - 덜 나쁜 자리 순위 = 계류 나란히 → 기울기 늘어남 → 옮긴 거리 · 꼴(합친 꼴)을 가로질러 견줌 - 크게 망침(기울기 넘친 만큼 +5 %p 초과 · 원래 선에서 10 m 초과)은 그것 없이 다 놓는 길이 없을 때만 · 그때 가장 덜 망치는 자리(곡선 가운데 = 측점 유지) - 놓을 자리 풀이 기억(층 · 다시 셈 공통) · ALGO_VERSION 9(옛 저장본 안 꺼냄) - 지킴 시험 test_764_1_snap_grade.py Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_Engine_RouteInitial_Merge.py | 5 + ...B05_Profile_Engine_RouteInitial_Station.py | 117 ++++++++++++------ .../B05_Profile_Engine_RouteInitial_Store.py | 5 +- resources/tester/test_764_1_snap_grade.py | 93 ++++++++++++++ 4 files changed, 183 insertions(+), 37 deletions(-) create mode 100644 resources/tester/test_764_1_snap_grade.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py index 82cf6e214..9af3016eb 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Merge.py @@ -167,6 +167,11 @@ class Guard: def ok(self, local: list[Xy], reference: list[Xy]) -> bool: return self.judge(local, reference)[0] + def rise(self, local: list[Xy], reference: list[Xy]) -> float: + """원지반 기울기가 상한을 넘은 만큼의 늘어남(옛 조각 대비) — 측점 맞춤 덜 나쁜 자리 고르기만 + 씀(요구 606-2 · 그 밖 잼은 419 그대로).""" + return self._look(local)[0] - self._look(reference)[0] + @staticmethod def piece(before: Xy, apex: Xy, after: Xy, radius: float) -> list[Xy] | None: """곡선 하나가 쓰는 조각 — 앞 직선 가운데 → 원호 → 뒤 직선 가운데(직선 절반 규칙).""" diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py index 5a6f7622a..1ff4e4331 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py @@ -24,7 +24,9 @@ R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마 (`..._Merge.Guard` · 복도 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐 · 원지반 기울기는 참고만 · 419)으로 거름. MC = 일반측점 필수 · 꺾임만(R 없는 교점 · 삼각) 없음(419): · 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터. - · 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에. + · 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에. 덜 나쁜 놓기는 원지반 + 기울기 늘어남 · 옮긴 거리를 따져 고르고 크게 망치는 놓기는 맨 뒤(다 막힐 때 그중 가장 덜 + 망치는 것 · 요구 606-2). · 곡선 끔 없음 — 내각 155° 이상도 곡선 · 꺾임만으로 들어온 자리도 곡선으로 놓아 봄 · 곡선을 못 끼우는 자리(나란함)는 교점 지움 · 이웃과 한 측점을 다투면 교점째 뺌. · 그래도 못 놓으면 그 곡선은 그 자리(측점 밖 · 마지막 수). @@ -80,6 +82,10 @@ RADIUS_STEPS = 8 BACKTRACK_LIMIT = 30 # 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로 PER_VARIANT = 3 +# 덜 나쁜 놓기(직접 잼 못 지남)가 「크게 망침」 — 원지반 기울기 상한 넘은 만큼이 옛 자리보다 이만큼 +# 넘게 늘거나 원래 선에서 이만큼(m) 넘게 옮김 · 다른 놓기가 다 막힐 때만 씀(요구 606-2) +RUIN_RISE = 0.05 +RUIN_GAP_M = STATION_M / 2 @dataclass @@ -316,6 +322,9 @@ class _Snapper: self.excess = excess self.guard = guard self.keep = False + # 놓기 층 — 0 잼 지남 · 1 덜 나쁨 · 2 크게 망침(2 는 없이 다 놓는 길이 없을 때만 · 606-2) + self.tiers: tuple[int, ...] = (0, 1) if guard is not None else (0,) + self.memo: dict[tuple, list[tuple]] = {} # 놓을 자리(`_found`) self.merged_first = guard is not None # 같은 쪽으로 합친 꼴부터 놓아 봄(406) def _steeper(self, local: list[Xy]) -> bool: @@ -338,15 +347,54 @@ class _Snapper: end_s = state.end_s + math.dist(state.end_xy, at) return _State(plan, state.k + 1, at, end_s, 0.0, state.last_station, state.placed) - def _placements(self, state: _State, plan: _Plan, soft: bool = False) -> Iterator[_State]: + def _placements( + self, state: _State, plan: _Plan, tier: int = 0 + ) -> Iterator[tuple[tuple, _State]]: """곡선 k 를 남은 측점에 놓은 걸음들 — 가까운 측점 · 지금 R 에 가까운 것부터. - 직접 잼(406)이면 잼을 지나는 자리만 · `soft` 면 못 지나는 자리(복도 안)만 — 잣대 나은 순.""" + 직접 잼(406)이면 `tier` 층 자리만 — 0 잼 지남 · 1 못 지남(복도 안 · 덜 나쁨) · 2 그중 크게 + 망침(`RUIN_RISE` · `RUIN_GAP_M`) — 잣대 나은 순 · 1 · 2 층은 원지반 기울기 늘어남 · 옮긴 + 거리도 따짐(요구 606-2 · 419 「기울기 참고만」을 이 자리만 풂).""" k = state.k + given = 0 + for _, rank, local, new_at, new_corner, new_mc, station in sorted( + (item for item in self._found(state, plan) if item[0] == tier), + key=lambda item: item[1], + ): + if self.guard is None and (self._far(local) or self._steeper(local)): + continue + placed = plan.copy() + placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3) + yield ( + rank, + _State( + placed, + k + 1, + new_corner.arc[-1], + new_mc + new_corner.length / 2, + new_corner.tangent, + station, + state.placed + 1, + ), + ) + given += 1 + if given >= PER_VARIANT: + return + + def _found(self, state: _State, plan: _Plan) -> list[tuple]: + """곡선 k 를 놓을 수 있는 자리 모두 — (층, 순위, 조각, IP, 곡선, MC, 측점) · 층 · 판(망침 + 다시 셈)마다 다시 풀지 않게 기억.""" + k = state.k + memo = (k, *state.end_xy, state.end_s, state.prev_tangent, state.last_station) + memo += (*plan.points[k - 1 : k + 2], plan.radii[k]) + if memo in self.memo: + return self.memo[memo] + found: list[tuple] = [] + self.memo[memo] = found before, at, after = plan.points[k - 1], plan.points[k], plan.points[k + 1] start = _try(state, plan, k, at, plan.radii[k] or self.min_radius) if start is None: - return + return found corner, mc = start floor_r = min(self.min_radius, corner.radius) half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, at, after)) / 2) @@ -360,7 +408,6 @@ class _Snapper: max(lowest, STATION_M * math.floor(mc / STATION_M)), max(lowest, STATION_M * math.ceil(mc / STATION_M)), } - found: list[tuple[tuple, list[Xy], Xy, _Corner, float, float]] = [] for station in stations: for radius in radii: solved = _solve(state, plan, k, station, radius) @@ -372,35 +419,25 @@ class _Snapper: middle = ((new_at[0] + after[0]) / 2, (new_at[1] + after[1]) / 2) local = [state.end_xy, *new_corner.arc, middle] rank: tuple = (tight, abs(station - mc), abs(radius - corner.radius)) + tier = 0 if self.guard is not None: # 직접 잼(406) — 잣대 가장 나은 자리부터 · 복도 밖은 버림 - passed, (out, along, earth) = self.guard.judge( - local, self.reference.window(local) - ) - if out or passed == soft: + window = self.reference.window(local) + passed, (out, along, earth) = self.guard.judge(local, window) + if out: continue rank = (along, tight, rank[1], earth, rank[2]) - found.append((rank, local, new_at, new_corner, new_mc, station)) - given = 0 - for _, local, new_at, new_corner, new_mc, station in sorted( - found, key=lambda item: item[0] - ): - if self.guard is None and (self._far(local) or self._steeper(local)): - continue - placed = plan.copy() - placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3) - yield _State( - placed, - k + 1, - new_corner.arc[-1], - new_mc + new_corner.length / 2, - new_corner.tangent, - station, - state.placed + 1, - ) - given += 1 - if given >= PER_VARIANT: - return + if not passed: + # 덜 나쁜 자리 — 기울기 늘어남 · 옮긴 거리를 순위 · 문턱에(요구 606-2) + rise = self.guard.rise(local, window) + gap = self.reference.gap(local) + ruin = max(rise / RUIN_RISE, gap / RUIN_GAP_M) + tier = 2 if ruin > 1 else 1 + rank = (along, round(rise, 2), tight, round(gap), earth, rank[2]) + if tier == 2: # 크게 망침 — 가장 덜 망치는 것부터 + rank = (round(ruin, 1), *rank) + found.append((tier, rank, local, new_at, new_corner, new_mc, station)) + return found def _options(self, state: _State) -> Iterator[_State]: """꺾임점 k 의 갈래 — 측점에 놓기 → 뒤 꺾임과 합쳐 놓기 → (완만하면) 곡선 끔.""" @@ -440,9 +477,14 @@ class _Snapper: same += 1 if self.merged_first: variants = variants[:same][::-1] + variants[same:] - for soft in (False, True) if self.guard is not None else (False,): - for trial in variants: - yield from self._placements(state, trial, soft) + for tier in self.tiers: + if not tier: + for trial in variants: + yield from (placed for _, placed in self._placements(state, trial)) + continue + # 덜 나쁨 · 크게 망침 — 꼴(합친 꼴 먼저)을 가로질러 순위대로(606-2) + found = [item for trial in variants for item in self._placements(state, trial, tier)] + yield from (placed for _, placed in sorted(found, key=lambda item: item[0])) # 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만(추천 후보는 # 안 함 · 155° 이상도 곡선 · 419) middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2) @@ -507,7 +549,7 @@ class _Snapper: if small == k: yield self._kink(state, off) else: - yield from self._placements(state, off) + yield from (placed for _, placed in self._placements(state, off)) yield from self._kept(state, off) def _kept(self, state: _State, plan: _Plan | None = None) -> Iterator[_State]: @@ -599,7 +641,12 @@ def snap_curves_to_stations( def _snap(snapper: _Snapper, min_radius_m: float) -> PlannedPolyline | None: """다 놓는 길 → 부분 맞춤 차례로 맞춘 선 · 못 하면 None.""" - state = snapper.run(keep=False) or snapper.run(keep=True) + state = snapper.run(keep=False) + if state is None and snapper.guard is not None: + # 크게 망치는 놓기는 그것 없이 다 놓는 길(이웃 곡선 다른 배치 포함)이 없을 때만(606-2) + snapper.tiers = (0, 1, 2) + state = snapper.run(keep=False) + state = state or snapper.run(keep=True) if state is None: return None snapped = build_planned_polyline( diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py index c19084da5..32817ae4d 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -40,8 +40,9 @@ from common_util.common_util_time import utc_now_text # 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) · # 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) · -# 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420) -ALGO_VERSION = 8 +# 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420) · +# 9 = 측점 맞춤 덜 나쁜 자리에 원지반 기울기 늘어남 · 옮긴 거리(요구 606-2) +ALGO_VERSION = 9 # 엔진 소스 지문에 드는 파일(저장소 뿌리 기준) — 추천 결과를 낳는 코드 SOURCE_GLOBS = ( "B05_Profile/B05_Profile_Engine_RouteInitial*.py", diff --git a/resources/tester/test_764_1_snap_grade.py b/resources/tester/test_764_1_snap_grade.py new file mode 100644 index 000000000..6f483dc05 --- /dev/null +++ b/resources/tester/test_764_1_snap_grade.py @@ -0,0 +1,93 @@ +"""764-1 추천 측점 맞춤 — 덜 나쁜 자리 고를 때 원지반 기울기 늘어남 · 옮긴 거리를 따짐(요구 606-2). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_764_1_snap_grade.py -q + +· 가까운 측점 자리가 비탈(기울기 늘어남)이면 다음 측점 자리로 — 옛 순위는 가까운 측점 · 토공을 + 앞세워 비탈 자리를 골랐음. +· 모든 자리가 크게 망치면 그중 가장 덜 망치는 자리(곡선 가운데 = 측점은 지킴). +· 판(`ALGO_VERSION`)이 다른 저장본은 꺼내지 않음. +""" + +import json +import math + +import pytest + +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import ( + STATION_M, + curve_mc_chainages, + off_station_count, + snap_curves_to_stations, +) +from B05_Profile.B05_Profile_Engine_RouteInitial_Store import ( + ALGO_VERSION, + read_stored_any, + store_dir, +) +from common_util.common_util_route_polyline import build_planned_polyline + +RADIUS = 12.0 +# MC ≈ 109.5 m — 가까운 측점 100 · 다음 120 +POINTS = [(0.0, 0.0), (110.0, 0.0), (230.0, 90.0)] + + +class _Slope: + """직접 잼 흉내 — 늘 못 지남(덜 나쁜 자리만 남음) · 곡선 가운데가 x < 112 면 비탈 쪽. + + 토공은 비탈 쪽이 더 작게(옛 순위가 비탈을 고르도록 유혹).""" + + def __init__(self, steep: float, flat: float) -> None: + self.steep, self.flat = steep, flat + + def rise(self, local, reference) -> float: + arc = local[1:-1] + return self.steep if sum(x for x, _ in arc) / len(arc) < 112.0 else self.flat + + def judge(self, local, reference): + return False, (False, 0.0, -1000.0 * self.rise(local, reference)) + + def ok(self, local, reference) -> bool: + return False + + +def _snap(guard) -> float: + outline = build_planned_polyline( + POINTS, + min_radius_m=RADIUS, + simplify=False, + curve_flags=[True] * 3, + radii=[None, 40.0, None], + ) + (mc,) = curve_mc_chainages(outline) + assert abs(mc - 100.0) < abs(mc - 120.0) # 가까운 측점 = 비탈 쪽 + snapped = snap_curves_to_stations(outline, RADIUS, guard=guard) + assert snapped is not None and off_station_count(snapped) == 0 + (placed,) = curve_mc_chainages(snapped) + return round(placed / STATION_M) * STATION_M + + +def test_less_bad_spot_avoids_slope() -> None: + """가까운 측점 자리가 기울기를 크게 늘리면 다음 측점 자리.""" + assert _snap(_Slope(steep=0.30, flat=0.0)) == 120.0 + + +def test_all_ruined_takes_least_ruin() -> None: + """모든 자리가 크게 망치면 그중 가장 덜 망치는 자리 · MC 는 측점에.""" + assert _snap(_Slope(steep=0.20, flat=0.40)) == 100.0 + assert _snap(_Slope(steep=0.40, flat=0.20)) == 120.0 + + +@pytest.mark.parametrize("version, found", [(ALGO_VERSION - 1, False), (ALGO_VERSION, True)]) +def test_other_version_not_read(tmp_path, version: int, found: bool) -> None: + folder = store_dir(tmp_path) + folder.mkdir(parents=True) + data = {"version": version, "source": "s", "pool": "p", "request": {}, "result": {}} + (folder / "k.json").write_text(json.dumps(data), encoding="utf-8") + got = read_stored_any(tmp_path, "p", lambda source: "k") + assert (got is not None) is found + + +def test_ruin_threshold_constants() -> None: + from B05_Profile.B05_Profile_Engine_RouteInitial_Station import RUIN_GAP_M, RUIN_RISE + + assert RUIN_RISE == pytest.approx(0.05) and math.isclose(RUIN_GAP_M, STATION_M / 2) From 0dbe4d5f310c7b5dee3d9be9e1bab5026669319c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 15:50:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(765-4):=20=EC=A7=80=ED=98=95=20?= =?UTF-8?q?=EC=B9=A9=20=3D=20=EB=B2=8C=20=EB=A7=9E=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=C2=B7=20LAS=20=EB=82=98=EC=A4=91=20=EC=98=AC=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=20=EB=92=A4=20=3D=20=ED=99=95=EC=9D=B8=EC=B0=BD=20=E2=86=92=20?= =?UTF-8?q?=EC=A0=84=EC=B2=98=EB=A6=AC=20=EB=8B=A4=EC=8B=9C(=EB=91=90=20?= =?UTF-8?q?=EB=B2=8C=20=C2=B7=20B05~B09=20=EC=B4=88=EA=B8=B0=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile_Router_TerrainSwitch: 칩 = swap_design_set(떠난 벌 [저장] 고침 살림 · 묶음 없음 · 묵음 = 한 번 지음 · 실패 = 통째 되돌림) · force = switch_terrain → run_surface_change_chain(765-5) - 확인창 글: 바꿈 = 미리 지어 둔 그 지형 설계로 바꿔 끼움 · 전처리 다시 = 두 벌 새로 · B05~B09 초기화 · LAS 올림 끝 = 늘 전처리 다시 - 시험: test_626_1 칩 시험을 맞바꿈 · 전처리 다시 기준으로(옛 노선 그대로 다시 셈 길 없음) · test_637 force 시험 · 화면 줄 · test_763_3 서피스 바꾸는 자리 목록에 지형 칩 Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_Router_TerrainSwitch.py | 76 +++++++------------ .../B05_Profile_UI_RouteEdit_Terrain.ts | 29 +++---- .../tester/test_626_1_recommend_preview.py | 69 ++++++++--------- resources/tester/test_637_lidar.py | 16 ++-- resources/tester/test_763_3_surface_change.py | 2 + 5 files changed, 82 insertions(+), 110 deletions(-) diff --git a/B05_Profile/B05_Profile_Router_TerrainSwitch.py b/B05_Profile/B05_Profile_Router_TerrainSwitch.py index ac821e0b0..343e7e88c 100644 --- a/B05_Profile/B05_Profile_Router_TerrainSwitch.py +++ b/B05_Profile/B05_Profile_Router_TerrainSwitch.py @@ -1,15 +1,15 @@ -"""B05 설계 지형 바꿈(PLAN 626-1 · 요구 530 · 결정 A④ · D2) — 평면 도구줄 지형 칩. +"""B05 설계 지형 바꿈(PLAN 626-1 · 765-4 · 요구 530 · 605 · 606-1) — 평면 도구줄 지형 칩. -`POST /api/projects/{id}/route/design-terrain` 본문 `{terrain: "las" | "sheet"}` → -설계 지형만 바꾸고(`switch_design_terrain`) 노선 저장 체인을 다시 탐 -(종단 · 횡단 · 배수 · 관 · 구조물 따라감 · -계획고 옮김 없음 = 옛 · 새 계획 폴리라인 같음). 노선 · 노드 · 곡선표 = 지금 저장본 그대로(안 씀). -초기값(`initial_snapshot/`) · 추천 저장본 · 예정노선 안 씀(옛 추천 [적용] 길 `as_initial` 대신). -[저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림(`common_util_save_guard`) · -같은 지형 = 바꿀 것 없음(`force` = 같은 지형도 체인 다시 탐 — LAS 갈음 뒤 · PLAN 637-2). +`POST /api/projects/{id}/route/design-terrain` 본문 `{terrain: "las" | "sheet", force}` +- 칩 = 맞바꿈(`common_util_design_sets.swap_design_set`) — 지금 벌(노선 · 종단 · 횡단 · 배수 · + 토공 · 수량 · 초기값)을 묶음으로 떠 두고 전처리가 미리 지은 고른 지형 벌을 꺼내 끼움 · 떠난 벌의 + [저장] 고침은 묶음에 살아 돌아오면 그대로 · 묶음 없음 · 지문 어긋남 = 그 자리에서 한 번 지음 · + 실패 = 통째 되돌림 · 같은 지형 = 바꿀 것 없음. +- `force` = LAS 나중 올리기 뒤 전처리 다시(765-5) — 고른 지형을 설계 지형으로 · B05~B09 초기화 · + 두 벌 다시(`run_surface_change_chain`). +[저장]과 같은 프로젝트 잠금(`common_util_save_guard`). 계산 자리 = ③ 서버 단독. """ -import asyncio import logging from typing import Literal from uuid import UUID @@ -18,10 +18,6 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel from B05_Profile.B05_Profile_Router_Terrain import router -from common_util.common_util_surface_confirmation import ( - design_terrain, - get_surface_confirmation_params, -) from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -29,7 +25,7 @@ logger = logging.getLogger(__name__) class DesignTerrainRequest(BaseModel): terrain: Literal["las", "sheet"] - #: 같은 지형도 다시 셈 — LAS 갈음 뒤 설계 지형이 라이다인 B(PLAN 637-2) + #: 전처리 다시(B05~B09 초기화 · 두 벌) — LAS 나중 올리기 뒤(765-5) force: bool = False @@ -37,45 +33,25 @@ class DesignTerrainRequest(BaseModel): async def switch_route_design_terrain( project_id: UUID, body: DesignTerrainRequest ) -> dict | JSONResponse: - """설계 지형을 바꾸고 뒤 단계를 모두 다시 셈 — 노선 값 · 초기값은 그대로.""" - from B05_Profile.B05_Profile_Router_Replan import ( - _PROJECT_PATH_MISSING, - _planned_line, - _project_paths, - _recompute, - ) - from B06_Section.B06_Section_Router_DesignSave import _restore + """설계 지형 벌 맞바꿈 · `force` = 전처리 다시.""" + from B03_FileInput.B03_FileInput_Service_SurfaceChange import run_surface_change_chain + from B05_Profile.B05_Profile_Router_Replan import _PROJECT_PATH_MISSING, _project_paths + from common_util import common_util_design_sets as sets from common_util import common_util_save_guard as guard - lock = guard.project_lock(str(project_id)) - async with lock: + async with guard.project_lock(str(project_id)): paths = await _project_paths(project_id) if paths is None: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) - project_root, stored_path = paths - pool = get_db_pool() - async with pool.acquire() as connection: - now = design_terrain(await get_surface_confirmation_params(connection, str(project_id))) - if now == body.terrain and not body.force: - return {"status": "success", "design": now, "changed": False} - rows = await guard.snapshot_rows(connection, str(project_id), surface=True) - files = await asyncio.to_thread( - guard.snapshot_storage, project_root, guard.STORAGE_SCOPE + guard.PIPE_SCOPE - ) try: - line = await asyncio.to_thread(_planned_line, project_root) - result = await _recompute( - project_id, project_root, stored_path, None, body.terrain, (line, line), False - ) - if "error" in result: - await _restore(project_id, rows, files) - return JSONResponse( - status_code=409, content={"status": "error", "message": result["error"]} - ) - except Exception: - await _restore(project_id, rows, files) - raise - finally: - files.discard() - logger.info("설계 지형 바꿈: project_id=%s %s → %s", project_id, now, body.terrain) - return {"status": "success", "design": body.terrain, "changed": True, **result} + if body.force: + async with get_db_pool().acquire() as connection: + model_id = await sets.switch_terrain(connection, project_id, body.terrain) + await run_surface_change_chain(project_id, model_id) + result = {"changed": True, "design": body.terrain, "preprocessed": True} + else: + result = await sets.swap_design_set(project_id, paths[0], body.terrain) + except (LookupError, RuntimeError) as exc: + return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)}) + logger.info("설계 지형 바꿈: project_id=%s %s", project_id, result) + return {"status": "success", **result} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts index 50bc03725..4e5c21157 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Terrain.ts @@ -2,10 +2,11 @@ * B05_Profile_UI_RouteEdit_Terrain.ts * 평면 설계 지형 칩(PLAN 626-1 · 요구 530 · 결정 A④ · D2) — 공용 지형 칩(`map_terrain_chip`)으로 설계 지형 보기 · 바꾸기. * 고를 수 있는 지형 = `/route/terrain-sources`(없는 쪽 막힘) · 바꾸면 확인창 → `POST /route/design-terrain` - * (설계 지형 바꿈 · 뒤 단계 모두 다시 셈 · 노선 값 · 초기값 · 추천 저장본 그대로 · 잠금 막) → 초안 비움 → 페이지 다시 세움. + * (765-4 맞바꿈 — 전처리가 미리 지은 그 지형 벌로 바꿔 끼움 · 떠난 벌의 [저장] 고침은 살아 돌아오면 그대로 · 잠금 막) + * → 초안 비움 → 페이지 다시 세움. * 취소 · 실패 = 칩을 지금 지형으로 되돌림. 옛 길(추천 [적용] 안 지형 고르기 · 509-5) 대신. * 칩 옆 [LAS 올리기](637-3 · 공용 `map_lidar_upload` · 예정노선과 같은 단추) → `POST /route/lidar` → 진행 서클(1 초 묻기) - * → 끝나면 칩 다시 읽고 확인창 「설계 지형을 라이다로 바꿈?」(이미 라이다 = 갈음 → `force` 다시 셈) · + * → 끝나면 칩 다시 읽고 확인창 「라이다로 전처리 다시」(`force` — 두 벌 새로 · B05~B09 초기화 · 765-5) · * 노선을 다 못 덮음(`covered` 거짓) = 라이다로 바꿈 막고 알림 · 설계 값은 확인창을 눌러야만 바뀜. * 부르는 곳 = `_Chrome`(도구줄 `terrain` 칸) · `_RouteEdit`(지형 읽은 뒤 `load`). * ========================================================================== */ @@ -82,7 +83,7 @@ export function createRouteTerrain(projectId: string): RouteTerrain { note(job); load({ sources: job.sources, design: job.design === "sheet" ? "sheet" : "las" }); if (uncovered != null) return warnUncovered(); - await change("las", design === "las"); + await change("las", true); // 전처리 다시 — 두 벌 새로 · B05~B09 초기화(765-5) } const note = (info: RouteLidar): void => { @@ -106,26 +107,28 @@ export function createRouteTerrain(projectId: string): RouteTerrain { if (info.running && !upload.busy()) void upload.follow(); } - /** 설계 지형 바꿈 — `force` = 같은 지형 다시 셈(라이다 갈음 뒤). */ + /** 설계 지형 바꿈(벌 맞바꿈) — `force` = 전처리 다시(LAS 나중 올리기 뒤). */ async function change(next: AnalysisTerrain, force = false): Promise { if (busy || upload.busy() || (next === design && !force)) return chip.set(toChip(design)); if (next === "las" && uncovered != null) { warnUncovered(); return chip.set(toChip(design)); } + const name = TERRAIN_NAMES[toChip(next)]; const ok = await askConfirm({ title: force - ? "설계 지형 다시 셈" + ? "라이다로 전처리 다시" : next === "las" ? "설계 지형을 라이다로 바꿈?" : "설계 지형 바꿈", - message: - (force - ? "새로 올린 라이다로 설계 지형을 다시 셈합니다 — 종단 · 횡단 · 배수 · 토공을 모두 다시 셈합니다. " - : `설계 지형을 ${TERRAIN_NAMES[toChip(next)]}(으)로 바꿉니다 — 종단 · 횡단 · 배수 · 토공을 모두 다시 셈합니다. `) + - "평면 노선 · 초기값([초기화])은 그대로 · 저장 안 한 고침은 버립니다." + - (force ? " 안 하면 설계 값은 옛 라이다 셈 그대로입니다." : ""), - okLabel: force ? "다시 셈" : "바꿈", + message: force + ? "새로 올린 라이다로 전처리를 다시 합니다 — 라이다 · 수치지형도 설계(노선 · 종단 · 횡단 · 배수 · " + + "토공 · 수량)를 새로 짓고 B05~B09 를 초기화합니다. 저장한 고침 · 초기값([초기화])도 새 값으로 " + + "바뀝니다. 안 하면 설계 값은 옛 지형 셈 그대로입니다." + : `설계 지형을 ${name}(으)로 바꿉니다 — 전처리 때 미리 지어 둔 ${name} 설계(노선 · 종단 · 횡단 · ` + + "배수 · 토공 · 수량)로 바꿔 끼웁니다. 지금 지형에서 [저장]한 고침은 남아 돌아오면 그대로 · " + + "저장 안 한 고침은 버립니다. 초기값([초기화])은 지형마다 따로입니다.", + okLabel: force ? "전처리 다시" : "바꿈", }); if (!ok) return chip.set(toChip(design)); busy = true; @@ -140,7 +143,7 @@ export function createRouteTerrain(projectId: string): RouteTerrain { clearDrafts(projectId); forgetDesignTerrainGrid(projectId); // 새 지형 격자로(509-2) showToast( - `설계 지형을 ${TERRAIN_NAMES[toChip(next)]}(으)로 ${force ? "다시 셈했" : "바꿨"}습니다.`, + force ? "라이다로 전처리를 다시 했습니다." : `설계 지형을 ${name}(으)로 바꿨습니다.`, "success", ); await rebuildAfterRouteSave(projectId); diff --git a/resources/tester/test_626_1_recommend_preview.py b/resources/tester/test_626_1_recommend_preview.py index 1cfe04a24..81429b83a 100644 --- a/resources/tester/test_626_1_recommend_preview.py +++ b/resources/tester/test_626_1_recommend_preview.py @@ -2,19 +2,18 @@ - 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 화면 부름 없음(서버 길 · 시험은 남김). - 미리보기 = 저장본 넷(영선 · 토공 · 기울기 · 비중) 하나만 · 끔 · 다시 셈(`recompute`) 없음. -- 지형 칩 → `POST /route/design-terrain` — 노선 그대로(옛 = 새 선) · 초기값 폴더 안 씀 · 같은 지형 = 그대로. +- 지형 칩 → `POST /route/design-terrain` — 벌 맞바꿈(765-4 · `swap_design_set`) · + 같은 지형 = 그대로 · `force` = 전처리 다시(765-5). """ import asyncio import re from pathlib import Path -from types import SimpleNamespace from uuid import uuid4 from B05_Profile import B05_Profile_Router_Replan as replan from B05_Profile import B05_Profile_Router_TerrainSwitch as switch -from B06_Section import B06_Section_Router_DesignSave as design_save -from common_util import common_util_save_guard as guard +from common_util import common_util_design_sets as sets ROOT = Path(__file__).resolve().parents[2] B05 = ROOT / "B05_Profile" @@ -60,22 +59,25 @@ def test_지형_칩_확인창_새_길() -> None: assert "rebuildAfterRouteSave(projectId)" in src -def _run(monkeypatch, now: str, want: str): +def _run(monkeypatch, now: str, want: str, force: bool = False): + """칩 요청 — 맞바꿈 · 전처리 다시가 받은 값만 잰다(본체 = test_765_design_sets).""" calls: dict = {} - line = [[0.0, 0.0], [10.0, 0.0]] async def paths(_pid): return Path("root"), "stored" - async def recompute(*args): - calls["args"] = args - return {"route_id": 7} + async def swap(pid, root, target): + calls["swap"] = (root, target) + if target == now: + return {"changed": False, "design": now} + return {"changed": True, "design": target, "built": False} - async def rows(*_a, **_k): - return {} + async def switch_terrain(_c, _pid, terrain): + calls["switch"] = terrain + return 9 - async def restore(*_a): - calls["restored"] = True + async def chain(pid, model_id): + calls["chain"] = model_id class Pool: def acquire(self): @@ -88,38 +90,31 @@ def _run(monkeypatch, now: str, want: str): return Ctx() - async def params(*_a): - return {} - - def storage(root, scope): - calls["scope"] = scope - return SimpleNamespace(discard=lambda: None) + import B03_FileInput.B03_FileInput_Service_SurfaceChange as change monkeypatch.setattr(replan, "_project_paths", paths) - monkeypatch.setattr(replan, "_planned_line", lambda _root: line) - monkeypatch.setattr(replan, "_recompute", recompute) - monkeypatch.setattr(design_save, "_restore", restore) - monkeypatch.setattr(guard, "snapshot_rows", rows) - monkeypatch.setattr(guard, "snapshot_storage", storage) + monkeypatch.setattr(sets, "swap_design_set", swap) + monkeypatch.setattr(sets, "switch_terrain", switch_terrain) + monkeypatch.setattr(change, "run_surface_change_chain", chain) monkeypatch.setattr(switch, "get_db_pool", lambda: Pool()) - monkeypatch.setattr(switch, "get_surface_confirmation_params", params) - monkeypatch.setattr(switch, "design_terrain", lambda _p: now) - body = switch.DesignTerrainRequest(terrain=want) + body = switch.DesignTerrainRequest(terrain=want, force=force) result = asyncio.run(switch.switch_route_design_terrain(uuid4(), body)) - return result, calls, line + return result, calls -def test_지형_바꿈_노선_그대로_초기값_안씀(monkeypatch) -> None: - result, calls, line = _run(monkeypatch, "las", "sheet") +def test_지형_바꿈_벌_맞바꿈(monkeypatch) -> None: + result, calls = _run(monkeypatch, "las", "sheet") assert result["changed"] is True and result["design"] == "sheet" - _pid, _root, _stored, balanced, terrain, lines, initial = calls["args"] - assert balanced is None and terrain == "sheet" and initial is False - assert lines == (line, line) # 계획고 옮김 없음 · 노선 안 씀 - assert guard.INITIAL_SCOPE[0] not in calls["scope"] - assert "restored" not in calls + assert calls == {"swap": (Path("root"), "sheet")} # 전처리 다시 아님 def test_같은_지형이면_그대로(monkeypatch) -> None: - result, calls, _ = _run(monkeypatch, "sheet", "sheet") + result, calls = _run(monkeypatch, "sheet", "sheet") assert result == {"status": "success", "design": "sheet", "changed": False} - assert "args" not in calls and "scope" not in calls + assert "chain" not in calls + + +def test_force_는_전처리_다시(monkeypatch) -> None: + result, calls = _run(monkeypatch, "las", "las", force=True) + assert result["preprocessed"] is True and result["design"] == "las" + assert calls == {"switch": "las", "chain": 9} # 맞바꿈 아님 · 두 벌 다시 diff --git a/resources/tester/test_637_lidar.py b/resources/tester/test_637_lidar.py index ab63aad3b..baa43d32e 100644 --- a/resources/tester/test_637_lidar.py +++ b/resources/tester/test_637_lidar.py @@ -347,14 +347,10 @@ def test_지형_바꿈_force_같은_지형도_다시_셈(monkeypatch) -> None: from B05_Profile import B05_Profile_Router_TerrainSwitch as switch - request = switch.DesignTerrainRequest - assert request(terrain="las").force is False - monkeypatch.setattr( - switch, "DesignTerrainRequest", lambda terrain: request(terrain=terrain, force=True) - ) - result, calls, line = base._run(monkeypatch, "las", "las") - assert result["changed"] is True and calls["args"][4] == "las" - assert calls["args"][5] == (line, line) # 노선 그대로 + assert switch.DesignTerrainRequest(terrain="las").force is False + result, calls = base._run(monkeypatch, "las", "las", force=True) + # LAS 갈음 뒤 = 전처리 다시(765-5 · 두 벌 · B05~B09 초기화) + assert result["changed"] is True and calls["switch"] == "las" and calls["chain"] == 9 # ── 637-3 B05 화면 ────────────────────────────────────────────────────────── @@ -367,8 +363,8 @@ def test_B05_칩_옆_올리기_확인창_force_덮임_막음() -> None: assert "const lidarPath = `/projects/${projectId}/route/lidar`;" in ui assert "root.append(chip.root, upload.button, circle.root);" in ui # 칩 옆 · 서클 assert "createProgressCircle({ size: 16, inline: true })" in ui - # 끝 = 칩 다시 · 못 덮음 = 막고 알림 · 이미 라이다 = force 다시 셈 · 확인창 거쳐서만 - assert 'await change("las", design === "las");' in ui + # 끝 = 칩 다시 · 못 덮음 = 막고 알림 · 전처리 다시(force · 765-5) · 확인창 거쳐서만 + assert 'await change("las", true);' in ui assert "if (uncovered != null) return warnUncovered();" in ui assert 'if (next === "las" && uncovered != null) {' in ui assert "JSON.stringify({ terrain: next, force })" in ui diff --git a/resources/tester/test_763_3_surface_change.py b/resources/tester/test_763_3_surface_change.py index fb030451c..de7ef93cd 100644 --- a/resources/tester/test_763_3_surface_change.py +++ b/resources/tester/test_763_3_surface_change.py @@ -244,6 +244,8 @@ _ALLOWED = { "B05_Profile/B05_Profile_Router_Replan.py", # 라이다 확정값만 적어 둠 · 설계 지형 그대로(637-2) "B05_Profile/B05_Profile_Router_Lidar.py", + # 지형 칩 = 벌 맞바꿈 · LAS 나중 올리기 뒤 전처리 다시(765-4 · 5 · 요구 605-1 · 606-1) + "B05_Profile/B05_Profile_Router_TerrainSwitch.py", } From 9b12d4ff5d2acab965b1affc75dba4c92cb41608 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 15:52:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(765-6):=20=EC=B4=88=EA=B8=B0=EA=B0=92?= =?UTF-8?q?=20=EC=82=AC=EB=B3=B8=EC=97=90=EC=84=9C=20=EB=B0=B0=EC=88=98=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EC=BA=90=EC=8B=9C=20=EB=BA=8C=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=EC=9D=80=20=EB=85=B8=EC=84=A0=20=EB=AA=AB?= =?UTF-8?q?=EB=A7=8C=20=EA=B0=88=EC=9D=8C(=EC=BA=90=EC=8B=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=9E=90=EB=A6=AC)=20=C2=B7=20=EB=B3=B5=EC=A0=9C=EA=B0=80=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=B2=8C=20=EB=AC=B6=EC=9D=8C=20=EC=95=88?= =?UTF-8?q?=20=EC=A7=80=ED=91=9C=EB=A9=B4=20id=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common_util_initial_snapshot: 배수 사본 = 노선 몫만(하강장 · 도엽 좌표 · 강우 뺌 · 약 105 → 20 MB) · 복원은 폴더 통째 지우지 않음 · 캐시 든 옛 사본의 캐시 칸은 건너뜀 - common_util_project_copy._fix_files(통째 복제 · --replace 같은 길): design_sets/<지형>/ 의 db.json · 그 벌 초기값 · B06 표본 설정 · meta.json 지표면 id → 새 id · 노선 id 는 꺼낼 때 늘 새로 받아 안 고침 · 코리도 안 건드림 - 시험 test_765_design_sets 에 둘 덧 Co-Authored-By: Claude Opus 5.5 --- common_util/common_util_initial_snapshot.py | 49 +++++++++++++- common_util/common_util_project_copy.py | 74 +++++++++++++++++---- resources/tester/test_765_design_sets.py | 50 ++++++++++++++ 3 files changed, 160 insertions(+), 13 deletions(-) diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index 07b8dfc9c..1ee7ca833 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -169,6 +169,49 @@ def _copy_tree(source: Path, target: Path) -> None: shutil.copytree(source, target) +_DRAINAGE_TREE = "B04_PreProcess/drainage" + + +def _drainage_shared(name: str) -> bool: + return name.startswith(DRAINAGE_SHARED_PREFIXES) + + +def _save_drainage(source: Path, target: Path) -> None: + """배수 폴더 중 노선 몫만 뜸 — 공유 캐시(하강장 · 도엽 좌표 · 강우)는 뺌(765-6 · + 약 105 → 20 MB).""" + if not source.is_dir(): + return + if target.exists(): + shutil.rmtree(target) + top = str(source) + shutil.copytree( + source, + target, + ignore=lambda folder, names: [n for n in names if folder == top and _drainage_shared(n)], + ) + + +def _restore_drainage(stored: Path, live: Path) -> None: + """노선 몫만 갈음 — 공유 캐시는 제자리에 둠(지우면 다음 배수 계산이 처음부터 · 765-6) · + 캐시 든 옛 사본도 캐시 칸은 건너뜀.""" + if live.is_dir(): + for path in live.iterdir(): + if _drainage_shared(path.name): + continue + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + live.mkdir(parents=True, exist_ok=True) + for path in stored.iterdir(): + if _drainage_shared(path.name): + continue + if path.is_dir(): + shutil.copytree(path, live / path.name) + else: + shutil.copy2(path, live / path.name) + + async def _dump_rows( connection: aiomysql.Connection, table: str, route_id: int ) -> list[dict[str, Any]]: @@ -211,7 +254,8 @@ async def save_initial_snapshot( _write_design_route_csv(target / DESIGN_ROUTE_CSV_NAME, design_route_points) for tree in _FILE_TREES: - _copy_tree(root / tree, target / tree.replace("/", "__")) + save = _save_drainage if tree == _DRAINAGE_TREE else _copy_tree + save(root / tree, target / tree.replace("/", "__")) # 코리도는 체인이 마지막에 만든다 — 있으면 함께 뜬다(없으면 브라우저 폴백 그대로). from B05_Profile.B05_Profile_Router_Corridor import corridor_path @@ -294,6 +338,9 @@ def restore_snapshot_files(project_root: Path, route_id: int | None = None) -> N else: # 배수 폴더 없는 스냅샷 — 관 편집은 비움(초기값에 없던 것) shutil.rmtree(edits, ignore_errors=True) continue + if tree == _DRAINAGE_TREE: + _restore_drainage(stored, root / tree) + continue _copy_tree(stored, root / tree) # 코리도는 복원으로 만든 **새 route id** 이름으로 되돌린다 — 이름이 어긋나면 브라우저가 diff --git a/common_util/common_util_project_copy.py b/common_util/common_util_project_copy.py index 1c3dc4a47..4331904d8 100644 --- a/common_util/common_util_project_copy.py +++ b/common_util/common_util_project_copy.py @@ -158,18 +158,68 @@ def _fix_files( print("프로젝트 id 치환:", replace_project_id(new_root, source_id, new_id)) print("노선 · 지표면 id 파일:", rekey_id_files(new_root, model_map, route_map)) - snapshot = new_root / "initial_snapshot" / "db.json" - if snapshot.is_file(): - dump = json.loads(snapshot.read_text(encoding="utf-8")) - changed = False - for route in dump.get("routes", []): - old_model = route.get("surface_model_id") - if old_model in model_map: - route["surface_model_id"] = model_map[old_model] - changed = True - if changed: - snapshot.write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8") - print("초기값 스냅샷 지표면 id 교정:", changed) + print( + "초기값 스냅샷 지표면 id 교정:", _fix_dump_models(new_root / "initial_snapshot", model_map) + ) + print("설계 벌 묶음 지표면 id 교정:", _fix_design_sets(new_root, model_map)) + + +def _fix_dump_models(folder: Path, model_map: dict) -> bool: + """`folder/db.json` 노선 행의 지표면 id → 새 id · 바꿨으면 참.""" + path = folder / "db.json" + if not path.is_file(): + return False + dump = json.loads(path.read_text(encoding="utf-8")) + changed = False + for route in dump.get("routes", []): + old_model = route.get("surface_model_id") + if old_model in model_map: + route["surface_model_id"] = model_map[old_model] + changed = True + if changed: + path.write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8") + return changed + + +def _fix_sampling(root: Path, model_map: dict) -> bool: + """`B06_Section/sampling.json` 의 지표면 id → 새 id(코리도는 안 건드림).""" + path = root / "B06_Section" / "sampling.json" + if not path.is_file(): + return False + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("surface_model_id") not in model_map: + return False + data["surface_model_id"] = model_map[data["surface_model_id"]] + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return True + + +def _fix_design_sets(root: Path, model_map: dict) -> dict[str, list[str]]: + """쉬는 설계 벌 묶음(765-6) 안 지표면 id → 새 id — db.json · 그 벌 초기값 · B06 표본 설정 · + meta.json. 노선 id 는 꺼낼 때 늘 새로 받아(묶음 안에서만 짝) 안 고침 → 묶음 → 고친 자리.""" + from common_util.common_util_design_sets import sets_dir + + report: dict[str, list[str]] = {} + base = sets_dir(root) + for bundle in sorted(base.iterdir()) if base.is_dir() else []: + fixed = [ + name + for name, done in ( + ("db.json", _fix_dump_models(bundle, model_map)), + ("initial_snapshot", _fix_dump_models(bundle / "initial_snapshot", model_map)), + ("sampling", _fix_sampling(bundle / "files", model_map)), + ) + if done + ] + meta_path = bundle / "meta.json" + if meta_path.is_file(): + meta = json.loads(meta_path.read_text(encoding="utf-8")) + if meta.get("model_id") in model_map: + meta["model_id"] = model_map[meta["model_id"]] + meta_path.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8") + fixed.append("meta.json") + report[bundle.name] = fixed + return report def _write_manifest(root: Path, manifest: dict[str, Any]) -> None: diff --git a/resources/tester/test_765_design_sets.py b/resources/tester/test_765_design_sets.py index 3df33836f..d9fbdac66 100644 --- a/resources/tester/test_765_design_sets.py +++ b/resources/tester/test_765_design_sets.py @@ -517,3 +517,53 @@ def test_비우기에_묶음_포함(tmp_path: Path) -> None: removed = purge_output_folders(tmp_path, keep_preprocess=True) assert SETS_DIR in removed and not (tmp_path / SETS_DIR).exists() assert (tmp_path / "B04_PreProcess" / "models").is_dir() + + +# ── 765-6 초기값 사본 = 캐시 뺌 · 복제가 묶음 안 지표면 id 고침 ──────────── + + +def test_초기값_사본은_배수_캐시를_빼고_복원은_캐시를_안_지움(tmp_path: Path) -> None: + from common_util import common_util_initial_snapshot as snap + + _seed_files(tmp_path) + drainage = tmp_path / "B04_PreProcess" / "drainage" + (drainage / "rainfall_stations").mkdir() + (drainage / "rainfall_stations" / "a.json").write_text("st", encoding="utf-8") + target = tmp_path / "initial_snapshot" / "B04_PreProcess__drainage" + snap._save_drainage(drainage, target) + saved = sorted(p.name for p in target.iterdir()) + assert saved == ["01_primary_region.geojson", "edits", "manifest.json"] # 캐시 없음 + + # 작업본이 바뀜 · 캐시는 새로 지어짐 · 옛 사본(캐시 듦) 꼴도 섞음 + (drainage / "01_primary_region.geojson").write_text("edited", encoding="utf-8") + (drainage / "04_detailed_basins.geojson").write_text("new", encoding="utf-8") + (drainage / "descent_ab_cd.npz").write_text("d2", encoding="utf-8") + (target / "00_sheet_metric.pkl").write_text("old-cache", encoding="utf-8") + snap._restore_drainage(target, drainage) + live = {p.name: p for p in drainage.iterdir()} + assert live["01_primary_region.geojson"].read_text(encoding="utf-8") == "{}" + assert "04_detailed_basins.geojson" not in live # 초기값에 없던 노선 몫 = 지움 + assert live["descent_ab_cd.npz"].read_text(encoding="utf-8") == "d2" # 캐시 그대로 + assert live["00_sheet_metric.pkl"].read_text(encoding="utf-8") == "s" # 옛 사본 캐시 안 덮음 + assert (live["rainfall_stations"] / "a.json").is_file() + assert (live["edits"] / "pipe_points.json").is_file() + + +def test_복제가_묶음_안_지표면_id_고침(tmp_path: Path) -> None: + from common_util.common_util_project_copy import _fix_design_sets + + db = _seed_db() + _seed_files(tmp_path) + asyncio.run(sets.park_active_set(_Conn(db), tmp_path, PID, FP)) + bundle = tmp_path / SETS_DIR / "las" + corridor = bundle / "files" / "B05_Profile" / "corridor" / "corridor_0011.json" + report = _fix_design_sets(tmp_path, {5: 905}) + assert sorted(report["las"]) == ["db.json", "meta.json", "sampling"] + dump = json.loads((bundle / "db.json").read_text(encoding="utf-8")) + assert {r["surface_model_id"] for r in dump["routes"]} == {905} + assert [r["id"] for r in dump["routes"]] == [10, 11] # 노선 id 는 꺼낼 때 새로 + assert sets.read_meta(tmp_path, "las")["model_id"] == 905 + sampling = bundle / "files" / "B06_Section" / "sampling.json" + assert json.loads(sampling.read_text(encoding="utf-8")) == {"surface_model_id": 905} + assert corridor.is_file() # 코리도는 안 건드림 + assert sets.bundle_ready(tmp_path, "las", FP) # 지문은 id 를 안 봄