diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index b846d9e9..4a658d9c 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -15,7 +15,8 @@ import { requestJson } from "./B05_Profile_Api_Replan"; const INITIAL_TIMEOUT_MS = 5 * 60 * 1000; /** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추적(처음 초기값). */ -export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted"; +export type RouteInitialLogic = + "follow" | "earthwork" | "gentle" | "short" | "balance" | "weighted"; /** 비중 계산의 세 비중(%) — 합 100. */ export interface RouteInitialWeights { diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 55570d39..6bdd8fa2 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -1,4 +1,6 @@ -"""초기 계획노선 갈래 — 예상노선 추종 · 토공 최소 · 기울기 순한 노선 · 비중(PLAN 20 · 23장). +"""초기 계획노선 갈래(PLAN 20 · 23장). + +예상노선 추종 · 토공 최소 · 기울기 순한 · 비중 · 짧은 노선 · 흙 균형. 계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이 받은 노드로 기존 `POST /route/replan`([확인])을 부른다. @@ -8,13 +10,17 @@ 제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다. · earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소. · gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소). - · weighted — 후보 중 예상노선 이격 · 절성토 · 기울기(최대, 같으면 평균) 세 값을 후보 - 범위로 0~1 정규화해 비중(합 100)으로 더한 값 최소. 끝값은 그 갈래와 같은 선 — - 토공 100 = 토공 최소 · 기울기 100 = 기울기 순한 · 예상 100 = 추적 선 그대로(23-3e). + · short — 후보 중 연장 최소(PLAN 23-4c). + · balance — 후보 중 흙 남음 |절토 − 성토| 최소 · 같으면 절성토 합 최소(PLAN 23-4c). + · weighted — 탐색 비용의 가중합 한 번(후보 고르기 아님 · PLAN 23-4a · `..._Pool.weighted`). + 끝값은 그 갈래와 같은 선 — 예상 100 = 추적 · 토공 100 = 토공 최소 · 기울기 100 = + 기울기 순한. 제약은 (1 − 예상 몫)만큼 벌점으로만 따지고 넘은 곳은 목록에 뜸. -후보 = 목표 비중(토공 · 기울기 · 이격 · 기본 비중)을 바꿔 가며 격자 탐색(`..._Search`)한 노선들. -갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는 비교값 -(측점 20 m 기울기 · 절성토 가늠)과 어긋나 탐색 한 벌로는 갈래 순서가 안 맞는다(PLAN 23-1). +후보 묶음(`..._Pool.build_pool`) = 목표를 바꿔 가며 격자 탐색(`..._Search`)한 노선들 + 토공 ↔ 기울기 +비중 쓸기. 갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는 +비교값(측점 20 m 기울기 · 절성토 가늠)과 어긋나 탐색 한 벌로는 갈래 순서가 안 맞는다(PLAN 23-1). +가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수 +있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4). 공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격: · 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이 @@ -46,6 +52,13 @@ from typing import Any import numpy as np from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics +from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import ( + Pool, + build_pool, + pick, + similar, + weighted, +) from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CORRIDOR_M, ROAD_WIDTH_M, @@ -70,24 +83,19 @@ STREAM_CLASSES = ("소하천", "지방하천", "국가하천") PENDING: tuple[str, ...] = () # ── 탐색 · 측정 값 ─────────────────────────────────────────────────────────────── -MODES = ("follow", "earthwork", "gentle", "weighted") +MODES = ("follow", "earthwork", "gentle", "weighted", "short", "balance") DEFAULT_WEIGHTS = {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} MODE_WEIGHTS = { "follow": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0}, "earthwork": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0}, "gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0}, + # 비중 몫이 없는 갈래 — 연장 · 흙 균형은 비교값으로만 고름 + "short": {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}, + "balance": {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}, } -# 후보를 만들 탐색 비중 — 목표 하나씩 -CANDIDATE_WEIGHTS = (MODE_WEIGHTS["earthwork"], MODE_WEIGHTS["gentle"], MODE_WEIGHTS["follow"]) -# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음 -GRADE_CAP_STEPS = (1.0, 0.93, 0.85) -# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게 -# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨) -SOFT_GRADE_COST = 500.0 -RELAXED_GRADE_COSTS = (50.0, 5.0) # 후보 묶음은 갈래와 무관 — 같은 입력이면 다시 짜지 않음(최근 몇 벌만 · 프로세스 메모리) POOL_CACHE_SIZE = 4 -_POOLS: dict[bytes, tuple[list[tuple[Any, dict[str, Any]]], float | None]] = {} +_POOLS: dict[bytes, Pool] = {} def load_terrain_and_streams( @@ -203,9 +211,9 @@ def generate_initial_route( ) -> dict[str, Any]: """갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14). - follow 밖 갈래는 후보(`_candidates`) 중 제 목표 비교값이 가장 좋은 것을 고른다. - `stream_offset_m`·`grade_smooth_m` 을 안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 - 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정). + follow 는 전처리 초기값 규칙 그대로 · weighted 는 비중 탐색 한 번(끝값은 그 갈래) · 나머지는 + 후보 묶음(`..._Pool`)에서 제 목표 비교값이 가장 좋은 것. `stream_offset_m`·`grade_smooth_m` 을 + 안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정). """ if mode not in MODES: raise ValueError(f"모르는 갈래: {mode}") @@ -215,7 +223,9 @@ def generate_initial_route( stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) - def measure(outline: Any, bound: float | None, follow: bool = False) -> dict[str, Any]: + def measure( + outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False + ) -> dict[str, Any]: return route_metrics( outline.vertices, expected, @@ -229,22 +239,25 @@ def generate_initial_route( bound_pct=None if bound is None else bound * 100.0, grade_smooth_m=grade_smooth, follow=follow, + weighted=weighted, ) def follow_line() -> tuple[Any, dict[str, Any]]: # 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 → # 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸. outline = build_planned_polyline(expected, min_radius_m=min_radius_m) - return outline, measure(outline, None, follow=True) + return outline, measure(outline, bound=None, follow=True) bound = None - # 비중 예상 100 = 예상노선 추적 그 선(PLAN 23-3e) — 사이 비중은 제약 지킨 후보에서만 고름 - if mode == "follow" or (mode == "weighted" and used["earthwork"] == used["grade"] == 0): + pool: Pool | None = None + target = _weighted_end(used) if mode == "weighted" else mode + key = _pool_key( + expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth + ) + if target == "follow": # 비중 예상 100 = 추적 그 선(PLAN 23-3e) · 묶음은 짜지 않음(빠른 길) outline, metrics = follow_line() + pool = _POOLS.get(key) else: - key = _pool_key( - expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth - ) if key not in _POOLS: grid = SearchGrid( expected, @@ -256,9 +269,13 @@ def generate_initial_route( graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m) while len(_POOLS) >= POOL_CACHE_SIZE: _POOLS.pop(next(iter(_POOLS))) - _POOLS[key] = _candidates(graph, max_grade, min_radius_m, measure) - candidates, bound = _POOLS[key] - outline, metrics = _pick(mode, used, candidates) + _POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure) + pool = _POOLS[key] + bound = pool.bound + if target == "weighted": + outline, metrics = weighted(pool, used, min_radius_m, measure) + else: + outline, metrics = pick(target, pool.candidates) return { "mode": mode, "logic": mode, @@ -270,6 +287,9 @@ def generate_initial_route( "nodes": [node.as_dict() for node in outline.nodes], "curves": [curve.as_dict() for curve in outline.curves], "metrics": metrics, + # 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만 + "similar": None if pool is None else similar(pool, target, outline), + "band_width_m": None if pool is None else round(pool.band_width_m, 1), "criteria": { "max_grade_pct": round(max_grade * 100.0, 2), "min_radius_m": round(min_radius_m, 2), @@ -283,57 +303,12 @@ def generate_initial_route( } -def _candidates( - graph: RouteGraph, - max_grade: float, - min_radius_m: float, - measure: Any, -) -> tuple[list[tuple[Any, dict[str, Any]]], float | None]: - """갈래가 고를 후보 노선들(폴리라인 · 비교값)과 지형상 불가능 때 막은 기울기(아니면 None). - - 제약을 지키는 길이 있으면 비중마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 · - 없으면 막은 기울기 안에서 벌점 크기 · 비중을 바꿔 찾은 노선들. 못 지킨 곳 수가 기준 - (엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. - """ - - def trace(nodes: list[tuple[float, float]], bound: float | None) -> tuple[Any, dict]: - # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선 - outline = build_planned_polyline( - nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes) - ) - return outline, measure(outline, bound) - - found: list[tuple[Any, dict[str, Any]]] = [] - for weights in CANDIDATE_WEIGHTS: - for step in GRADE_CAP_STEPS: - try: - nodes = graph.solve(weights, cap=max_grade * step, strict_stream=True) - except NoRoute: - break - found.append(trace(nodes, None)) - if found[-1][1]["over_limit"]["total"] == 0: - break - if not found: # 제약을 지키는 길 없음 — 비중과 무관하니 더 볼 것 없음 - break - if found: - least = min(item[1]["over_limit"]["total"] for item in found) - return [item for item in found if item[1]["over_limit"]["total"] == least], None - - # 지형상 불가능 — 반지름 · 계류 규칙을 지키는 어떤 길도 넘어야 하는 가장 작은 기울기를 - # 구해(`terrain_bound`) 그 위는 막고, 상한과 그 사이는 벌점으로 넘긴다. - bound = graph.terrain_bound() - cap = bound + 1e-9 - seed = trace( - graph.solve(DEFAULT_WEIGHTS, cap=cap, strict_stream=False, grade_cost=SOFT_GRADE_COST), - bound, - ) - found = [seed] - for weights in CANDIDATE_WEIGHTS: - for cost in RELAXED_GRADE_COSTS: - nodes = graph.solve(weights, cap=cap, strict_stream=False, grade_cost=cost) - found.append(trace(nodes, bound)) - most = seed[1]["over_limit"]["total"] - return [item for item in found if item[1]["over_limit"]["total"] <= most], bound +def _weighted_end(weights: dict[str, float]) -> str: + """비중이 한쪽 100 이면 그 갈래(끝값 = 갈래와 같은 선 · PLAN 23-3e) · 아니면 비중 탐색.""" + for name, mode in (("expected", "follow"), ("earthwork", "earthwork"), ("grade", "gentle")): + if all(value == 0 for key, value in weights.items() if key != name): + return mode + return "weighted" def _pool_key( @@ -348,34 +323,3 @@ def _pool_key( digest.update(np.ascontiguousarray(array).tobytes()) digest.update(repr((streams, numbers)).encode()) return digest.digest() - - -def _pick( - mode: str, weights: dict[str, float], candidates: list[tuple[Any, dict[str, Any]]] -) -> tuple[Any, dict[str, Any]]: - """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보.""" - - def earth(metrics: dict[str, Any]) -> float: - return metrics["cut_m3"] + metrics["fill_m3"] - - def grade(metrics: dict[str, Any]) -> float: - # 최대 먼저 · 평균은 같을 때만(기울기 순한과 같은 순서 — 값은 0.01 단위라 평균 몫 < 0.01) - return metrics["max_grade_pct"] + 1e-4 * metrics["avg_grade_pct"] - - def offset(metrics: dict[str, Any]) -> float: - return metrics["offset_avg_m"] - - if mode == "earthwork": - return min(candidates, key=lambda item: earth(item[1])) - if mode == "gentle": - return min( - candidates, key=lambda item: (item[1]["max_grade_pct"], item[1]["avg_grade_pct"]) - ) - # 비중 — 세 값을 후보 범위로 0~1 로 맞춘 뒤 비중으로 더함(단위가 다른 값을 겨루게) - parts = [] - for key, value in (("expected", offset), ("earthwork", earth), ("grade", grade)): - values = [value(item[1]) for item in candidates] - low, span = min(values), max(values) - min(values) - parts.append([weights[key] * ((v - low) / span if span > 1e-9 else 0.0) for v in values]) - scores = [sum(column) for column in zip(*parts)] - return candidates[scores.index(min(scores))] diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py index bc5070b0..0b3196fe 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py @@ -33,6 +33,8 @@ REASON_STREAM = "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을 REASON_FOLLOW_GRADE = "예상노선 추적 — 예상노선이 지나는 지반이 상한을 넘음(제약 탐색 안 함)" REASON_FOLLOW_STREAM = "예상노선 추적 — 예상노선이 계류 버퍼 안을 지남(제약 탐색 안 함)" REASON_STREAM_TERRAIN = "지형상 불가능 — 기울기 · 반지름을 지키며 계류 버퍼를 비켜 잇는 길 없음" +REASON_WEIGHTED_GRADE = "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(측점 사이 지반이 상한을 넘음)" +REASON_WEIGHTED_STREAM = "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(계류 버퍼 안을 지남)" def smoothed_ground(terrain: Terrain, grade_smooth_m: float = GRADE_SMOOTH_M) -> np.ndarray: @@ -113,11 +115,13 @@ def route_metrics( bound_pct: float | None, grade_smooth_m: float = GRADE_SMOOTH_M, follow: bool = False, + weighted: bool = False, ) -> dict[str, Any]: """비교값과 제약 못 지킨 구간 목록. 종단기울기 = 측점(20 m) 사이 고른 지반 기울기. `bound_pct` 는 제약을 다 지키는 길이 없을 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%). - `follow` 는 예상노선 추적(제약 탐색 없이 예상노선 그대로) — 사유가 그 뜻으로 뜬다. + `follow` 는 예상노선 추적(제약 탐색 없이 예상노선 그대로) · `weighted` 는 비중 계산(제약을 + 벌점으로만 따짐) — 사유가 그 뜻으로 뜬다. """ grid = (terrain.y, terrain.x) sampler = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=None) @@ -149,7 +153,13 @@ def route_metrics( float(ends[last]), float(grades[first : last + 1].max()) * 100.0, max_grade * 100.0, - REASON_FOLLOW_GRADE if follow else terrain_reason if relaxed else REASON_GRADE_FIT, + REASON_FOLLOW_GRADE + if follow + else REASON_WEIGHTED_GRADE + if weighted + else terrain_reason + if relaxed + else REASON_GRADE_FIT, ) ) @@ -187,6 +197,8 @@ def route_metrics( stream_offset_m, REASON_FOLLOW_STREAM if follow + else REASON_WEIGHTED_STREAM + if weighted else REASON_STREAM_TERRAIN if relaxed else REASON_STREAM, @@ -228,6 +240,8 @@ def route_metrics( "raw_max_grade_pct": round(float(raw_grades.max()) * 100.0 if raw_grades.size else 0.0, 2), "cut_m3": round(cut, 1), "fill_m3": round(fill, 1), + # 흙 남음(절토 − 성토 · + = 남아 버릴 흙 · − = 모자라 들여올 흙) — 흙 균형 갈래의 목표 + "surplus_m3": round(cut - fill, 1), "over_limit": {**counts, "total": len(violations)}, "offset_avg_m": round(float(offsets.mean()), 2), "offset_max_m": round(float(offsets.max()), 2), diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py new file mode 100644 index 00000000..874fbad7 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -0,0 +1,267 @@ +"""초기 계획노선 갈래의 후보 묶음 · 비중 탐색(PLAN 23-1 · 23-4). + +`B05_Profile_Engine_RouteInitial` 이 부름. + +후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다: + · 제약 탐색 후보 — 목표 하나씩(토공 · 기울기 · 이격 · 연장) × 탐색 상한 단계(지킬 수 있을 + 때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로. + · 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌. + 갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤ + 쓸기 최대 기울기). + · 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. + +갈래는 **비교값으로** 고른다(`pick`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면 평균) · +짧은 노선 = 연장 · 흙 균형 = |절토 − 성토|(같으면 절성토 합). + +비중 계산(`weighted`)은 고르기가 아니라 **탐색 비용의 가중합** 한 번(PLAN 23-4a): + · 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 · + 기울기 순한) 사이 범위로 나눠(`scale`) 비중 1 이 비슷한 크기로 움직이게. + · 제약은 막지 않는다 — 벌점 = (1 − 예상 몫) × 센 벌점(기울기 · 계류). 예상 100 이면 벌점 0 + (추적 노선도 넘는 지형) · 예상 0 이면 갈래와 같은 세기. 넘은 곳은 `violations` 에 뜬다. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from shapely.geometry import LineString + +from B05_Profile.B05_Profile_Engine_RouteInitial_Search import LENGTH_COST, NoRoute, RouteGraph +from common_util.common_util_route_polyline import build_planned_polyline + +ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0} +TARGETS = { + "earthwork": {**ZERO, "earthwork": 100.0}, + "gentle": {**ZERO, "grade": 100.0}, + "follow": {**ZERO, "expected": 100.0}, + "short": dict(ZERO), # 목표 몫 없음 — 거리 비용만(`SHORT_LENGTH_COST`) +} +# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음 +GRADE_CAP_STEPS = (1.0, 0.93, 0.85) +# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게 +# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨) +SOFT_GRADE_COST = 500.0 +RELAXED_GRADE_COSTS = (50.0, 5.0) +# 짧은 노선 탐색의 1 m 당 거리 비용 — 꺾기 벌점(원호 길이)과 같은 단위라 「연장 최소」가 됨 +SHORT_LENGTH_COST = 1.0 +# 토공 100 → 기울기 100 쓸기 벌 수(양 끝 포함 · 10 % 걸음) +SWEEP_STEPS = 11 +# 끝 노선 사이 범위가 링크 평균의 이 몫보다 작으면(끝 노선끼리 같음) 링크 평균으로 크기를 맞춤 +MIN_RANGE_SHARE = 0.05 +# 두 노선 사이 가장 먼 거리가 이보다 작으면 「같은 노선」으로 알림(`similar` · PLAN 23-4) +SIMILAR_M = 10.0 +# 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기 +CONSTRAINT_SHARE = 1.0 + +Measure = Callable[..., dict[str, Any]] +Route = tuple[Any, dict[str, Any]] + + +@dataclass +class Pool: + """같은 입력의 후보 묶음 — 갈래 고르기 · 비중 탐색이 같이 쓴다.""" + + graph: RouteGraph + candidates: list[Route] + bound: float | None + limit: int # 후보로 남길 못 지킨 곳 수 상한 + # 비중 몫 크기(1 m 당) — 이격 · 토공 · 기울기 · 넘은 기울기 · 계류 나란히 + scale: np.ndarray = field(default_factory=lambda: np.ones(5)) + # 비중 탐색 상한 — 예상 0 이면 갈래 상한(`base_cap`) · 예상 100 이면 추적 노선이 지나는 + # 가장 센 링크 기울기(`follow_cap`) · 사이는 예상 몫만큼(추적 쪽으로 갈수록 덜 막음) + base_cap: float = np.inf + follow_cap: float = np.inf + searched: dict[tuple[float, ...], Route] = field(default_factory=dict) + traced: dict[tuple, Route] = field(default_factory=dict) # 노드 → 잰 노선(`_trace`) + follow: Route | None = None + # 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m) + band_width_m: float = 0.0 + + +def _trace( + nodes: list[tuple[float, float]], + min_radius_m: float, + measure: Measure, + traced: dict[tuple, Route], + **kw: Any, +) -> Route: + # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선. + # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). + key = (tuple(nodes), tuple(sorted(kw.items()))) + if key not in traced: + outline = build_planned_polyline( + nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes) + ) + traced[key] = (outline, measure(outline, **kw)) + return traced[key] + + +def _length_cost(weights: dict[str, float]) -> float: + return LENGTH_COST if any(weights.values()) else SHORT_LENGTH_COST + + +def _candidates( + graph: RouteGraph, + max_grade: float, + min_radius_m: float, + measure: Measure, + traced: dict[tuple, Route], +) -> tuple[list[Route], float | None, int]: + """제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한. + + 제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 · + 없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들. + """ + found: list[Route] = [] + for weights in TARGETS.values(): + for step in GRADE_CAP_STEPS: + try: + nodes = graph.solve( + weights, + cap=max_grade * step, + strict_stream=True, + length_cost=_length_cost(weights), + ) + except NoRoute: + break + found.append(_trace(nodes, min_radius_m, measure, traced, bound=None)) + if found[-1][1]["over_limit"]["total"] == 0: + break + if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음 + break + if found: + least = min(item[1]["over_limit"]["total"] for item in found) + return [item for item in found if item[1]["over_limit"]["total"] == least], None, least + + # 지형상 불가능 — 반지름 · 계류 규칙을 지키는 어떤 길도 넘어야 하는 가장 작은 기울기를 + # 구해(`terrain_bound`) 그 위는 막고, 상한과 그 사이는 벌점으로 넘긴다. + bound = graph.terrain_bound() + cap = bound + 1e-9 + seed = _trace( + graph.solve( + {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}, + cap=cap, + strict_stream=False, + grade_cost=SOFT_GRADE_COST, + ), + min_radius_m, + measure, + traced, + bound=bound, + ) + found = [seed] + for weights in TARGETS.values(): + for cost in RELAXED_GRADE_COSTS: + nodes = graph.solve( + weights, + cap=cap, + strict_stream=False, + grade_cost=cost, + length_cost=_length_cost(weights), + ) + found.append(_trace(nodes, min_radius_m, measure, traced, bound=bound)) + most = seed[1]["over_limit"]["total"] + return [item for item in found if item[1]["over_limit"]["total"] <= most], bound, most + + +def build_pool( + graph: RouteGraph, + follow: Route, + max_grade: float, + min_radius_m: float, + measure: Measure, +) -> Pool: + """후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.""" + traced: dict[tuple, Route] = {} + candidates, bound, limit = _candidates(graph, max_grade, min_radius_m, measure, traced) + pool = Pool(graph, candidates, bound, limit, traced=traced, follow=follow) + ends = [follow[0], pick("earthwork", candidates)[0], pick("gentle", candidates)[0]] + pool.scale, steepest = _scale(graph, [outline.vertices for outline in ends], max_grade) + pool.base_cap = (max_grade if bound is None else bound) + 1e-9 + pool.follow_cap = max(steepest + 1e-9, pool.base_cap) + for k in np.linspace(0.0, 1.0, SWEEP_STEPS): + weights = {"expected": 0.0, "earthwork": 100.0 * k, "grade": 100.0 * (1.0 - k)} + route = weighted(pool, weights, min_radius_m, measure) + if route[1]["over_limit"]["total"] <= limit: + pool.candidates.append(route) + lines = list( + {id(outline): LineString(outline.vertices) for outline, _ in pool.candidates}.values() + ) + pool.band_width_m = max( + (a.hausdorff_distance(b) for i, a in enumerate(lines) for b in lines[i + 1 :]), default=0.0 + ) + return pool + + +def similar(pool: Pool, mode: str, outline: Any) -> list[dict[str, Any]]: + """이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m). + + 화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.""" + here = LineString(outline.vertices) + others = {"follow": pool.follow, **{name: pick(name, pool.candidates) for name in GOALS}} + found = [] + for name, (other, _) in others.items(): + if name == mode or other is None: + continue + gap = here.hausdorff_distance(LineString(other.vertices)) + if gap < SIMILAR_M: + found.append({"logic": name, "max_gap_m": round(gap, 1)}) + return sorted(found, key=lambda item: item["max_gap_m"]) + + +def _scale( + graph: RouteGraph, lines: list[list[tuple[float, float]]], max_grade: float +) -> tuple[np.ndarray, float]: + """비중 몫의 크기(1 m 당) — 끝 노선 셋 사이 몫 적분 범위 / 추적 연장 · 추적의 가장 센 기울기. + + 비중 1 을 옮기면 몫마다 「끝 노선 사이 차이의 1 %」만큼 무게가 옮겨 가 몫끼리 영향이 비슷하다. + 끝 노선끼리 그 몫이 거의 같으면(평지 등) 링크 평균으로 맞춤. + """ + measured = [graph.grid.objectives(line, limit=max_grade) for line in lines] + values = np.array([value for value, _ in measured]) + reference = max(float(np.hypot(*np.diff(np.asarray(lines[0]), axis=0).T).sum()), 1.0) + spread = (values.max(axis=0) - values.min(axis=0)) / reference + per_link = np.vstack([graph.terms, graph.over / graph.limit, graph.along]) + mean = (per_link * graph.length).sum(axis=1) / max(float(graph.length.sum()), 1e-9) + return np.where(spread > MIN_RANGE_SHARE * mean, spread, mean), measured[0][1] + + +def weighted(pool: Pool, weights: dict[str, float], min_radius_m: float, measure: Measure) -> Route: + """비중 계산 — 탐색 비용의 가중합 한 번(같은 비중은 다시 안 찾음). 제약은 벌점만.""" + key = tuple(round(float(weights[name]), 6) for name in ZERO) + if key not in pool.searched: + share = weights["expected"] / max(sum(weights.values()), 1e-9) + soft = CONSTRAINT_SHARE * (1.0 - share) + nodes = pool.graph.solve( + weights, + cap=pool.base_cap + share * (pool.follow_cap - pool.base_cap), + strict_stream=False, + grade_cost=soft / max(pool.scale[3], 1e-12), + stream_cost=soft / max(pool.scale[4], 1e-12), + scale=pool.scale[:3], + ) + pool.searched[key] = _trace( + nodes, min_radius_m, measure, pool.traced, bound=pool.bound, weighted=True + ) + return pool.searched[key] + + +def _earth(metrics: dict[str, Any]) -> float: + return metrics["cut_m3"] + metrics["fill_m3"] + + +GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = { + "earthwork": lambda m: (_earth(m),), + "gentle": lambda m: (m["max_grade_pct"], m["avg_grade_pct"]), + "short": lambda m: (m["length_m"], _earth(m)), + "balance": lambda m: (abs(m["cut_m3"] - m["fill_m3"]), _earth(m)), +} + + +def pick(mode: str, candidates: list[Route]) -> Route: + """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보.""" + goal = GOALS[mode] + return min(candidates, key=lambda item: goal(item[1])) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py index 53e3d625..7680ed37 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py @@ -15,6 +15,7 @@ from dataclasses import dataclass import numpy as np from scipy import ndimage +from scipy.interpolate import RegularGridInterpolator from scipy.sparse import csr_matrix from scipy.sparse.csgraph import breadth_first_order, connected_components, dijkstra @@ -156,6 +157,51 @@ class SearchGrid: terms = np.stack([offset, earth, (grade / limit) ** 2]) return a[ok], b[ok], self.cell * norm, terms, over, along, grade + def objectives( + self, vertices: list[tuple[float, float]], *, limit: float + ) -> tuple[np.ndarray, float]: + """폴리라인 하나의 몫 적분(`links` 의 1 m 당 값 × 길이)과 지나는 가장 센 링크 기울기. + + 몫 = 이격 · 토공 가늠 · 기울기 · 넘은 기울기(/ 상한) · 계류 버퍼 안 나란히(m) — 칸 길이 + 간격으로 잰다. 비중 계산의 크기 맞춤(끝 노선 셋 사이 범위)에 쓴다 — 탐색 경로가 아닌 + 선(추적)도 잰다. + """ + xy = np.asarray(vertices, dtype=np.float64) + chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))]) + at = np.linspace(0.0, chain[-1], max(2, int(chain[-1] / self.cell) + 1)) + points = np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])]) + mid = 0.5 * (points[1:] + points[:-1]) + step = np.diff(points, axis=0) + length = np.maximum(np.hypot(step[:, 0], step[:, 1]), 1e-9) + + def sample(field: np.ndarray, where: np.ndarray) -> np.ndarray: + field = np.where(np.isfinite(field), field, 1e9) + return RegularGridInterpolator( + (self.ys, self.xs), field, bounds_error=False, fill_value=None + )(where[:, ::-1]) + + grade = np.abs(np.diff(sample(self.z, points))) / length + over = np.maximum(0.0, grade - limit) + normal = np.column_stack([-step[:, 1], step[:, 0]]) / length[:, None] + cross = np.abs( + sample(self.grad_x, mid) * normal[:, 0] + sample(self.grad_y, mid) * normal[:, 1] + ) + earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0 + away = np.column_stack([sample(self.away_x, mid), sample(self.away_y, mid)]) + size = np.hypot(away[:, 0], away[:, 1]) + across = np.abs((away * step).sum(axis=1)) >= STREAM_CROSS_COS * length * size + along = (sample(self.d_stream, mid) < self.stream_offset_m) & ~across & (size > 1e-6) + terms = np.stack( + [ + np.minimum(sample(self.d_expected, mid), CORRIDOR_M), + earth, + (grade / limit) ** 2, + over / limit, + along.astype(np.float64), + ] + ) + return (terms * length).sum(axis=1), float(grade.max(initial=0.0)) + def _turn_steps(min_radius_m: float, cell: float) -> int: """꺾은 뒤 곧게 가야 할 칸 수 — 앞뒤 원호 접선이 한 직선에 들어가게. @@ -226,6 +272,7 @@ class RouteGraph: shape=(self.size, self.size), ) self.indices, self.indptr = order.indices, order.indptr + self._frame: tuple | None = None # 마지막으로 쓴 간선 틀(`_graph`) edge = (order.data - 1.0).astype(np.int64) self.edge_link = link_of[edge] self.edge_turn = np.concatenate(turn)[edge] @@ -243,15 +290,22 @@ class RouteGraph: return usable & ~self.along if strict_stream else usable def _graph(self, usable: np.ndarray, link_cost: np.ndarray | None) -> csr_matrix: - """쓸 수 있는 간선만 남긴 그래프 + 시점 상태 모두로 가는 원점(마지막 상태 `size`).""" - keep = usable[self.edge_link] - indptr = np.concatenate([[0], np.cumsum(keep)])[self.indptr] - indptr = np.append(indptr, indptr[-1] + len(self.starts)) - indices = np.concatenate([self.indices[keep], self.starts]) + """쓸 수 있는 간선만 남긴 그래프 + 시점 상태 모두로 가는 원점(마지막 상태 `size`). + + 틀(남길 간선 · 행 포인터)은 같은 `usable` 이면 다시 쓴다 — 후보 묶음은 같은 상한으로 + 여러 번 찾는다(비용만 다름).""" + signature = usable.tobytes() + if self._frame is None or self._frame[0] != signature: + keep = usable[self.edge_link] + indptr = np.concatenate([[0], np.cumsum(keep)])[self.indptr] + indptr = np.append(indptr, indptr[-1] + len(self.starts)) + indices = np.concatenate([self.indices[keep], self.starts]) + self._frame = (signature, keep, indptr, indices, self.edge_link[keep]) + _, keep, indptr, indices, links = self._frame if link_cost is None: data = np.ones(indices.size) else: - cost = link_cost[self.edge_link[keep]] + self.edge_turn[keep] + cost = link_cost[links] + self.edge_turn[keep] data = np.concatenate([cost, np.full(len(self.starts), 1e-9)]) return csr_matrix((data, indices, indptr), shape=(self.size + 1, self.size + 1)) @@ -268,24 +322,29 @@ class RouteGraph: cap: float, strict_stream: bool, grade_cost: float = 0.0, + stream_cost: float = SOFT_STREAM_COST, + scale: np.ndarray | None = None, + length_cost: float = LENGTH_COST, ) -> list[tuple[float, float]]: """노드(꺾임점) 목록 — 첫 · 끝은 예상노선 양 끝 그대로. - 세 목표는 쓸 수 있는 링크 평균이 1 이 되게 정규화(길이 가중)한 뒤 비중을 곱한다. - `grade_cost` = 상한을 넘은 기울기 벌점(1 m 당 × 넘은 비율 / 상한) — `cap` 이 상한보다 - 클 때(지형상 불가능)만 뜻이 있다. + 세 목표는 `scale`(1 m 당 크기)로 나눈 뒤 비중을 곱한다 — 안 주면 쓸 수 있는 링크 평균이 + 1 이 되게(길이 가중). `grade_cost` = 상한을 넘은 기울기 벌점(1 m 당 × 넘은 비율 / 상한) · + `stream_cost` = 계류 버퍼 안 나란히 벌점(엄격하지 않을 때만) · + `length_cost` = 1 m 당 거리 비용. """ usable = self._usable(cap, strict_stream) if not usable.any(): raise NoRoute("예상노선 둘레에 제약을 지키며 지날 수 있는 링크가 없습니다.") - length = self.length[usable] - scale = (self.terms[:, usable] * length).sum(axis=1) / max(length.sum(), 1e-9) + if scale is None: + length = self.length[usable] + scale = (self.terms[:, usable] * length).sum(axis=1) / max(length.sum(), 1e-9) share = np.array([weights[key] for key in OBJECTIVES]) / max(sum(weights.values()), 1e-9) factor = np.divide(share, scale, out=np.zeros_like(share), where=scale > 1e-12) penalty = grade_cost * self.over / self.limit if not strict_stream: - penalty = penalty + SOFT_STREAM_COST * self.along - link_cost = self.length * (LENGTH_COST + factor @ self.terms + penalty) + penalty = penalty + stream_cost * self.along + link_cost = self.length * (length_cost + factor @ self.terms + penalty) distance, predecessors = dijkstra( self._graph(usable, link_cost), indices=self.size, return_predecessors=True ) diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index d9a7a24d..a3b76d94 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -108,9 +108,10 @@ class RouteInitialCriteria(BaseModel): class RouteInitialRequest(BaseModel): - """초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted.""" + """초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted · + short(연장 최소) · balance(흙 균형 · PLAN 23-4c).""" - mode: Literal["follow", "earthwork", "gentle", "weighted"] = Field( + mode: Literal["follow", "earthwork", "gentle", "weighted", "short", "balance"] = Field( "follow", validation_alias=AliasChoices("logic", "mode") ) weights: RouteInitialWeights | None = None diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 4b5ea6c6..d0578760 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -19,6 +19,8 @@ export const LOGIC_LABEL: Record = { follow: "예상노선 추적", earthwork: "토공 최소", gentle: "기울기 순한", + short: "짧은 노선", + balance: "흙 균형", weighted: "비중 계산", }; @@ -120,6 +122,8 @@ export interface InitialMetrics { meanGradePct: number | null; cutM3: number | null; fillM3: number | null; + /** 남는 흙(㎥) = 절토 − 성토 — + 남음 / − 모자람. 서버 `surplus_m3`. */ + surplusM3: number | null; overLimitCount: number | null; offsetM: number | null; /** 제약 못 지킨 구간 — 서버 `metrics.violations`. 칸이 없으면 null(옛 답). */ @@ -155,6 +159,16 @@ export interface InitialChoice { criteriaOverrides: RouteInitialCriteria | null; /** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */ terrainBoundPct?: number | null; + /** 이 노선과 거의 같은(10 m 안) 다른 갈래 — 서버 `similar`. 없으면 null(옛 답 · 묶음 없음). */ + similar?: SimilarRoute[] | null; + /** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */ + bandWidthM?: number | null; +} + +/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */ +export interface SimilarRoute { + logic: RouteInitialLogic; + gapM: number; } const num = (value: unknown): number | null => @@ -229,6 +243,19 @@ export function mapPaths( })); } +/** 서버 `similar` — 갈래 이름을 아는 것만 담는다. 목록이 아니면 null · 빈 목록이면 빈 배열. */ +function parseSimilar(raw: unknown): SimilarRoute[] | null { + if (!Array.isArray(raw)) return null; + const out: SimilarRoute[] = []; + for (const item of raw as Array>) { + const logic = typeof item?.logic === "string" ? item.logic : ""; + const gap = num(item?.max_gap_m); + if (logic in LOGIC_LABEL && gap !== null) + out.push({ logic: logic as RouteInitialLogic, gapM: gap }); + } + return out; +} + /** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). * `criteriaOverrides` 는 이 요청에 실어 보낸 제약 겹침(빈 칸 뺀 것) — 캐시·재열기에 남긴다. */ export function normalizeRouteInitial( @@ -257,6 +284,7 @@ export function normalizeRouteInitial( meanGradePct: pick(m, "avg_grade_pct"), cutM3: pick(m, "cut_m3"), fillM3: pick(m, "fill_m3"), + surplusM3: pick(m, "surplus_m3"), overLimitCount: pick(over, "total"), offsetM: pick(m, "offset_avg_m"), violations: Array.isArray(m.violations) @@ -271,6 +299,8 @@ export function normalizeRouteInitial( } : null, terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null, + similar: parseSimilar(body.similar), + bandWidthM: num(body.band_width_m), criteria: body.criteria ? { maxGradePct: pick(c, "max_grade_pct"), @@ -351,6 +381,39 @@ const fixed = (value: number | null, digits: number, unit: string): string => ? "—" : `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`; +/** 남는 흙(절토 − 성토) — 부호를 늘 붙여 + 남음 / − 모자람으로 읽힌다. */ +export function surplusText(surplusM3: number): string { + const rounded = Math.round(surplusM3); + if (rounded === 0) return "0㎥(균형)"; + const sign = rounded > 0 ? "+" : "−"; + return `${sign}${Math.abs(rounded).toLocaleString("ko-KR")}㎥(${rounded > 0 ? "남음" : "모자람"})`; +} + +/** 조사 「과 / 와」 — 받침이 있으면 과. */ +const withParticle = (name: string): string => { + const code = name.charCodeAt(name.length - 1); + const hasFinal = code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0; + return `${name}${hasFinal ? "과" : "와"}`; +}; + +/** 같은 노선 알림 한 줄 — 이 지형은 제약 안에서 갈 길이 한 띠뿐일 때. 없으면 빈 글(줄을 숨긴다). + * 「이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 토공 최소 · 짧은 노선과 거의 같은 노선(가장 먼 거리 6.1m)」 */ +export function similarLine(choice: InitialChoice | null): string { + const similar = choice?.similar; + if (!choice || !similar || similar.length === 0) return ""; + const names = similar.map((item) => LOGIC_LABEL[item.logic]).join(" · "); + const gap = Math.max(...similar.map((item) => item.gapM)); + const band = + choice.bandWidthM === null || choice.bandWidthM === undefined + ? "" + : `폭 ${fixed(choice.bandWidthM, 1, "m")} `; + return `이 지형은 제약 안에서 갈 길이 ${band}한 띠뿐 — ${withParticle(names)} 거의 같은 노선(가장 먼 거리 ${fixed(gap, 1, "m")})`; +} + +/** 같은 노선 알림 아래 흐린 글 — 제약을 바꾸면 갈래가 벌어질 수 있다. */ +export const SIMILAR_HINT = + "기울기 · 최소R · 계류 제약 값을 바꾸면 갈래가 서로 벌어질 수 있습니다(다시 셈)."; + /** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */ export function metricsLine(metrics: InitialMetrics | null): string { if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다."; @@ -358,6 +421,7 @@ export function metricsLine(metrics: InitialMetrics | null): string { `연장 ${fixed(metrics.lengthM, 1, "m")}`, `기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`, `절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`, + ...(metrics.surplusM3 === null ? [] : [`남는 흙 ${surplusText(metrics.surplusM3)}`]), `기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`, `이격 ${fixed(metrics.offsetM, 1, "m")}`, ].join(" · "); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index f86fe93d..e78d004b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -29,6 +29,8 @@ import { inputWeight, moveWeight, relaxedLine, + SIMILAR_HINT, + similarLine, violationsLine, violationText, WEIGHT_KEYS, @@ -44,12 +46,21 @@ import "./B05_Profile_UI_Style_RouteRecommend.css"; type Vertex = [number, number]; -const PILLS: RouteInitialLogic[] = ["follow", "earthwork", "gentle", "weighted"]; +const PILLS: RouteInitialLogic[] = [ + "follow", + "earthwork", + "gentle", + "short", + "balance", + "weighted", +]; const PILL_TITLE: Record = { follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다", earthwork: "절토 · 성토가 가장 적은 노선", gentle: "종단기울기가 가장 순한 노선", + short: "연장이 가장 짧은 노선(제약 지킴)", + balance: "절토와 성토가 비슷해 남는 흙이 가장 적은 노선", weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선", }; @@ -196,6 +207,8 @@ export function openRecommendModal(params: RecommendParams): void {
+ +
@@ -299,6 +312,11 @@ export function openRecommendModal(params: RecommendParams): void { const relaxed = relaxedLine(preview); line("relaxed").textContent = relaxed; line("relaxed").hidden = !relaxed; + const similar = similarLine(preview); + line("similar").textContent = similar; + line("similar").hidden = !similar; + line("similar-hint").textContent = similar ? SIMILAR_HINT : ""; + line("similar-hint").hidden = !similar; line("criteria").textContent = preview ? criteriaLine(preview.criteria, params.planMinRadiusM) : ""; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index d38f48ae..357b6e80 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -25,6 +25,7 @@ import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field } from "./B05_Profile_UI_Structures_Fields"; import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options"; import { openItemPicker } from "@ui/ui_template_item_picker"; +import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { bindStructuresEvents, @@ -501,6 +502,10 @@ export function createStructuresSection( current: summary.get(), canCopy: true, projectId: sectionOptions.projectId ?? null, + // 복사본은 구조물마다 — 고치는 구조물의 id(관 지점 = `pipe@측점`) · 추가 전이면 null(원본 보기만) + structureId: + editingId ?? + (selectedPipeChainage !== null ? pipeStructureId(selectedPipeChainage) : null), }).then((result) => { if (result.action === "cancel") return; summary.set(result.action === "apply" ? result.item : null); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css index 8d00da23..60b2de8d 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css @@ -281,6 +281,13 @@ line-height: 1.5; } +/* 같은 노선 알림 아래 흐린 글. */ +.b05-routerec__lines .is-dim { + color: var(--color-text-secondary); + font-size: var(--text-caption); + opacity: 0.8; +} + /* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */ .b05-routerec__lines .is-warn { color: var(--color-warning); diff --git a/M02_MasterTemplete/M02_Item_Copy.py b/M02_MasterTemplete/M02_Item_Copy.py index fc40d21f..7708e664 100644 --- a/M02_MasterTemplete/M02_Item_Copy.py +++ b/M02_MasterTemplete/M02_Item_Copy.py @@ -118,6 +118,7 @@ def list_items(project_root: str | Path) -> list[dict[str, Any]]: "원본없음": changed is None, "구조물": manifest.get("구조물") or [], "고침": bool(manifest.get("고침")), + "값있음": bool(manifest.get("값있음")), } ) return out @@ -257,9 +258,13 @@ def reapply(project_root: str | Path, folder: str) -> dict[str, Any]: ] source = version_of(source_files(files)) stamp = now_text() - fresh = {**manifest, "판": source, "원본판": source, "가져온날": stamp} + # 원본을 다시 가져왔으니 시트 고침은 없음 — 살린 입력값 · 고른 자재는 `값있음` 으로 따로 알림 + kept = bool(files[VALUES_FILE] or files[PICKS_FILE]) + fresh = {**manifest, "판": source, "원본판": source, "가져온날": stamp, "고침": False} + fresh["값있음"] = kept + fresh.pop("고친날", None) atomic_write_json(target / MANIFEST_NAME, fresh) - return {"폴더": folder, "가져온날": stamp, "원본판": source, "덮임": covered} + return {"폴더": folder, "가져온날": stamp, "원본판": source, "덮임": covered, "값있음": kept} refresh = reapply # 25-1b 옛 이름(새로 가져오기) diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index 16573519..bada657b 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -298,6 +298,63 @@ const weightLocks = { }, }; +// ⑨ 추천 갈래 여섯 · 남는 흙 · 같은 노선 알림(계획서 23-4c 화면). +const withExtras = (extra, metricExtra = {}) => + model.normalizeRouteInitial( + { ...plainResponse, ...extra, metrics: { ...plainResponse.metrics, ...metricExtra } }, + "short", + null, + ); +const sixth = { + labels: Object.fromEntries( + ["follow", "earthwork", "gentle", "short", "balance", "weighted"].map((k) => [k, model.LOGIC_LABEL[k]]), + ), + surplus: [122.4, -578.2, 0, 0.4].map((v) => model.surplusText(v)), + metricsPlus: model.metricsLine(withExtras({}, { surplus_m3: 122.4 }).metrics), + metricsMinus: model.metricsLine(withExtras({}, { surplus_m3: -578 }).metrics), + metricsNone: model.metricsLine(withExtras({}).metrics), + similarTwo: model.similarLine( + withExtras({ + band_width_m: 13.5, + similar: [ + { logic: "earthwork", max_gap_m: 0.0 }, + { logic: "gentle", max_gap_m: 6.1 }, + ], + }), + ), + similarOne: model.similarLine( + withExtras({ band_width_m: 13.54, similar: [{ logic: "balance", max_gap_m: 6.1 }] }), + ), + similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "weighted", max_gap_m: 9.1 }] })), + similarEmpty: model.similarLine(withExtras({ band_width_m: 13.5, similar: [] })), + similarMissing: model.similarLine(withExtras({})), + similarNull: model.similarLine(null), + similarUnknown: withExtras({ similar: [{ logic: "nope", max_gap_m: 1 }, { logic: "short", max_gap_m: "x" }] }).similar, + bandParsed: withExtras({ band_width_m: 13.5 }).bandWidthM, + hint: model.SIMILAR_HINT, + weightedViolation: model.violationsLine({ + ...plain.metrics, + violations: [ + { + kind: "grade", + station: "No.9+0.0", + value: 29.1, + limit: 14, + reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(측점 사이 지반이 상한을 넘음)", + }, + ], + }), + weightedViolationReason: model.violationText({ + kind: "grade", + station: "No.9+0.0", + value: 29.1, + limit: 14, + reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음", + }), + shortLabel: model.choiceLabel("short", { expected: 0, earthwork: 0, grade: 0 }), + balanceLabel: model.choiceLabel("balance", null), +}; + // ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다. model.writeInitialChoice("p-1", plain); const readBack = model.readInitialChoice("p-1"); @@ -341,5 +398,6 @@ process.stdout.write( historyRecs, sweep, weightLocks, + sixth, }), ); diff --git a/resources/tester/helper_item_picker_project.cjs b/resources/tester/helper_item_picker_project.cjs index 25343dbf..55ad7a21 100644 --- a/resources/tester/helper_item_picker_project.cjs +++ b/resources/tester/helper_item_picker_project.cjs @@ -75,8 +75,26 @@ globalThis.fetch = async (url, init = {}) => { const [, , folderEnc, partEnc] = m; if (!folderEnc && method === "GET") return reply(200, { 항목: Object.values(state.folders).map((x) => x.info) }); + if (folderEnc === "new" && method === "POST") { + if (state.noCompany) + return reply(409, { detail: "회사부터 등록 — 개인 층은 회사 연결이 필요합니다." }); + const item = { + layer: "personal", + owner: "1/3", + key: "SS900001", + name: [...body.경로, body.이름].join(" "), + }; + return reply(200, { + key: "SS900001", + 이름: item.name, + 폴더: body.structure, + summary_item: item, + }); + } if (!folderEnc && method === "POST") { - const folder = `${body.layer}_${(body.owner ?? "none").replace(/\//g, "-")}_${body.key}`; + if (!body.structure) + return reply(400, { detail: "미리 가져올 구조물(structure)이 필요합니다." }); + const folder = body.structure.replace("#", "n"); state.folders[folder] = { info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" }, parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null }, @@ -86,9 +104,24 @@ globalThis.fetch = async (url, init = {}) => { const item = state.folders[decodeURIComponent(folderEnc)]; if (!item) return reply(404, { detail: "복사본이 없음" }); const part = partEnc && decodeURIComponent(partEnc); - if (part === "refresh") { + if (part === "reapply") { item.parts.면적 = { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }; - return reply(200, { 폴더: item.info.폴더, 가져온날: "2026-09-30T02:00:00", 원본판: "새판" }); + return reply(200, { + 폴더: item.info.폴더, + 가져온날: "2026-09-30T02:00:00", + 원본판: "새판", + 덮임: ["면적"], + }); + } + if (part === "save-as" && method === "POST") { + if (state.noCompany) + return reply(409, { detail: "회사부터 등록 — 개인 층은 회사 연결이 필요합니다." }); + const name = body.이름 ?? "복사"; + return reply(200, { + key: "SS900002", + 이름: name, + summary_item: { layer: "personal", owner: "1/3", key: "SS900002", name }, + }); } if (part === "calc") return reply(200, { @@ -122,17 +155,24 @@ const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner async function main() { const out = {}; - out.folders = [ - project.folderOf(row()), - project.folderOf(row({ layer: "company", owner: "3" })), - project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })), - ]; + // 구조물 id → 복사본 폴더(서버 `folder_name`) · 관 지점 id(서버 `pipe@{round(측점, 3)}` 파이썬 float 글) + out.folders = ["st-1", "#3", "pipe@85.05"].map(project.structureFolder); + out.pipeIds = [85.05, 100, 258.12, 0.1234, 12.0004].map(project.pipeStructureId); const items = project.projectItems("P1"); out.listEmpty = await items.list(); - const info = await items.importItem(row({ layer: "personal", owner: "1/3" })); + const info = await items.importItem(row({ layer: "personal", owner: "1/3" }), "pipe@85.05"); out.info = info; out.importCall = state.calls.find((c) => c.method === "POST"); + out.sameItem = [ + project.sameItem(info, row({ layer: "personal", owner: "1/3" })), + project.sameItem(info, row({ layer: "personal", owner: "1/3", key: "SS000002" })), + project.sameItem(info, row()), + ]; + await items.importItem(row(), "").then( + () => (out.noStructure = "성공"), + (error) => (out.noStructure = error.message), + ); out.list = (await items.list()).map((x) => x.폴더); out.read = await items.read(info.폴더, "면적"); @@ -164,11 +204,25 @@ async function main() { ); state.readOnly = false; - const fresh = await items.refresh(info); - out.refresh = fresh; - out.refreshCall = state.calls - .filter((c) => /refresh$/.test(c.url)) + const again = await items.reapply(info); + out.reapply = again; + out.reapplyCall = state.calls + .filter((c) => /reapply$/.test(c.url)) .map((c) => `${c.method} ${c.url}`); + out.saveAs = await items.saveAs(info.폴더, ["sub5", "경로"], "내 항목"); + out.saveAsCall = state.calls.find((c) => /save-as$/.test(c.url)).body; + out.newItem = await items.newItem("pipe@85.05", ["개인", "폴더"], "새 항목", "m"); + out.newCall = state.calls.find((c) => /items\/new$/.test(c.url)).body; + state.noCompany = true; + await items.saveAs(info.폴더, [], "x").then( + () => (out.noCompanySave = "성공"), + (error) => (out.noCompanySave = error.message), + ); + await items.newItem("pipe@85.05", [], "x", "").then( + () => (out.noCompanyNew = "성공"), + (error) => (out.noCompanyNew = error.message), + ); + state.noCompany = false; out.calc = await items.calc(info.폴더); out.calcMissing = await items.calc("없는폴더"); diff --git a/resources/tester/spreadsheet/test_spreadsheet_size.py b/resources/tester/spreadsheet/test_spreadsheet_size.py index 549afd08..57f4c888 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_size.py +++ b/resources/tester/spreadsheet/test_spreadsheet_size.py @@ -76,4 +76,6 @@ def test_M02_만_늦게_불러옴(): users.append(rel) # 서버 Node 진입만 정적(브라우저 번들 밖) assert offenders == ["common_util/common_util_spreadsheet_node.ts"], offenders - assert users and all(u.startswith("M02_MasterTemplete/") for u in users), users + # M02 화면 + 고르기 모달 편집 화면(카드 [수정] 을 누를 때만 `import(`) + later = ("M02_MasterTemplete/", "ui_template/ui_template_item_picker_edit.ts") + assert users and all(u.startswith(later) for u in users), users diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index be5cb5e7..ece139ab 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -246,3 +246,59 @@ def test_비중_값_칸_직접_입력(results): "ok": False, "weights": {"expected": 70, "earthwork": 20, "grade": 10}, } + + +def test_추천_갈래_여섯_이름과_가중치_없음(results): + six = results["sixth"] + assert six["labels"] == { + "follow": "예상노선 추적", + "earthwork": "토공 최소", + "gentle": "기울기 순한", + "short": "짧은 노선", + "balance": "흙 균형", + "weighted": "비중 계산", + } + # 짧은 노선 · 흙 균형은 비중이 없다(weights 0/0/0 이 와도 이름만) + assert six["shortLabel"] == "짧은 노선" and six["balanceLabel"] == "흙 균형" + page = os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend.ts") + with open(page, encoding="utf-8") as handle: + text = handle.read() + order = [ + text.index(f'"{name}"') for name in ("follow", "earthwork", "gentle", "short", "balance") + ] + assert order == sorted(order) # 알약 순서: 추적 · 토공 · 기울기 · 짧은 · 균형 · 비중 + + +def test_비교값_남는_흙(results): + six = results["sixth"] + assert six["surplus"] == ["+122㎥(남음)", "−578㎥(모자람)", "0㎥(균형)", "0㎥(균형)"] + assert six["metricsPlus"].endswith( + "성토 900㎥ · 남는 흙 +122㎥(남음) · 기준 초과 2구간 · 이격 3.3m" + ) + assert "남는 흙 −578㎥(모자람)" in six["metricsMinus"] + # 서버가 안 주면 칸이 없다(옛 답) — 기존 비교값 글 그대로 + assert "남는 흙" not in six["metricsNone"] + + +def test_같은_노선_알림_줄(results): + six = results["sixth"] + assert six["similarTwo"] == ( + "이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — " + "토공 최소 · 기울기 순한과 거의 같은 노선(가장 먼 거리 6.1m)" + ) + assert six["similarOne"] == ( + "이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 흙 균형과 거의 같은 노선(가장 먼 거리 6.1m)" + ) + # 띠 폭이 없어도 줄은 선다 · 받침 없는 이름은 「와」 + assert "폭" not in six["similarNoBand"] and "비중 계산과 거의" in six["similarNoBand"] + # 없으면 줄 숨김 — 빈 목록 · 칸 없음 · 선택 없음 + assert six["similarEmpty"] == "" and six["similarMissing"] == "" and six["similarNull"] == "" + assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목은 버림 + assert six["bandParsed"] == 13.5 + assert "제약 값을 바꾸면" in six["hint"] + + +def test_비중_계산_넘은_구간_사유_그대로(results): + six = results["sixth"] + assert six["weightedViolation"] == "제약 못 지킨 구간 1곳: No.9+0.0 29.1%(비중 계산)" + assert six["weightedViolationReason"] == "No.9+0.0 29.1%(비중 계산)" diff --git a/resources/tester/test_b05_routeedit_initial_roundtrip.py b/resources/tester/test_b05_routeedit_initial_roundtrip.py index a3e9d833..14d7d9a0 100644 --- a/resources/tester/test_b05_routeedit_initial_roundtrip.py +++ b/resources/tester/test_b05_routeedit_initial_roundtrip.py @@ -43,6 +43,8 @@ LOGICS: list[tuple[str, dict[str, float] | None]] = [ ("follow", None), ("earthwork", None), ("gentle", None), + ("short", None), + ("balance", None), ("weighted", {"expected": 50, "earthwork": 25, "grade": 25}), ] diff --git a/resources/tester/test_item_copy_complete.py b/resources/tester/test_item_copy_complete.py index 7e064363..597fc81b 100644 --- a/resources/tester/test_item_copy_complete.py +++ b/resources/tester/test_item_copy_complete.py @@ -128,6 +128,7 @@ def test_지정하면_새_파일까지_복사(world): "원본없음": False, "구조물": ["pipe@40.0"], "고침": False, + "값있음": False, } ] @@ -522,3 +523,19 @@ def test_새_항목_길_회사_없으면_409(world, monkeypatch): assert client.post("/api/projects/p1/items/new", json=body).status_code == 409 save_as = client.post("/api/projects/p1/items/f1/save-as", json={"경로": ["a"], "이름": "b"}) assert save_as.status_code == 409 + + +def test_재적용_뒤_고침은_꺼지고_값있음만_남음(world): + root = world["root"] + _save(root, [_pipe()]) + _memo(root, FOLDER, "x") + assert copies.list_items(root)[0]["고침"] is True + got = copies.reapply(root, FOLDER) + assert got["값있음"] is False + row = copies.list_items(root)[0] + assert row["고침"] is False and row["값있음"] is False + _fill_values(root) # 입력값 씀 — 고침 켜짐 + assert copies.list_items(root)[0]["고침"] is True + assert copies.reapply(root, FOLDER)["값있음"] is True + row = copies.list_items(root)[0] + assert row["고침"] is False and row["값있음"] is True diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index 63956516..c261a3e4 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -36,26 +36,28 @@ def out(): return json.loads(proc.stdout) -def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out): - rows = [ - {"layer": "system", "owner": None, "key": "SS000001"}, - {"layer": "company", "owner": "3", "key": "SS000001"}, - {"layer": "personal", "owner": "3/12", "key": "SS000002"}, +def test_구조물_id_는_복사본_폴더와_관_지점_id_로(out): + assert out["folders"] == ["st-1", "n3", "pipe@85.05"] # `#차례` = `n차례` (서버 folder_name) + # 서버 `pipe@{round(측점, 3)}` — 파이썬 float 글(정수도 `100.0`) + assert out["pipeIds"] == [ + f"pipe@{round(x, 3)}" for x in (85.05, 100.0, 258.12, 0.1234, 12.0004) ] - assert out["folders"] == [snapshot_folder_name(row) for row in rows] - assert out["info"]["폴더"] == snapshot_folder_name( - {"layer": "personal", "owner": "1/3", "key": "SS000001"} - ) -def test_가져오기는_POST_원본_참조_목록은_항목(out): +def test_가져오기는_구조물마다_POST_본문에_structure(out): assert out["listEmpty"] == [] assert out["importCall"] == { "method": "POST", "url": "/api/projects/P1/items", - "body": {"layer": "personal", "owner": "1/3", "key": "SS000001"}, + "body": {"structure": "pipe@85.05", "layer": "personal", "owner": "1/3", "key": "SS000001"}, } - assert out["list"] == [out["info"]["폴더"]] + assert out["info"]["폴더"] == "pipe@85.05" # 폴더 = 구조물 id + assert out["list"] == ["pipe@85.05"] + assert out["noStructure"] == "미리 가져올 구조물(structure)이 필요합니다." # 서버 까닭 그대로 + + +def test_같은_구조물이_다른_항목으로_바뀌었는지_원본_참조로_가림(out): + assert out["sameItem"] == [True, False, False] def test_읽기는_내용만_돌려주고_없으면_null(out): @@ -76,14 +78,31 @@ def test_판이_달라지면_409_까닭_권한_없으면_서버_까닭_그대로 assert out["readOnly"] == "프로젝트 편집 권한이 없음" -def test_새로_가져오기와_계산_한_줄(out): - assert out["refreshCall"][0].startswith("POST /api/projects/P1/items/") - assert out["refreshCall"][0].endswith("/refresh") - assert out["refresh"]["마스터바뀜"] is False and out["refresh"]["원본판"] == "새판" +def test_재적용은_덮임을_돌려주고_계산_한_줄(out): + assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply" + assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로 + assert out["reapply"]["info"]["마스터바뀜"] is False assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1} assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음 +def test_개인에_저장과_새_항목_본문_summary_item(out): + assert out["saveAsCall"] == {"to": "personal", "경로": ["sub5", "경로"], "이름": "내 항목"} + assert out["saveAs"]["summary_item"]["key"] == "SS900002" + assert out["newCall"] == { + "structure": "pipe@85.05", + "경로": ["개인", "폴더"], + "이름": "새 항목", + "단위": "m", + } + assert out["newItem"]["summary_item"]["name"] == "개인 폴더 새 항목" + + +def test_회사_없음_409_는_서버_까닭_그대로(out): + for key in ("noCompanySave", "noCompanyNew"): + assert out[key].startswith("회사부터 등록"), key + + def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out): assert out["inputsHits"] == 1 assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]] @@ -164,3 +183,20 @@ def test_소스가_700줄을_넘지_않음(): M02 / "M02_MasterTemplete_Inputs_Model.ts", ): assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name + + +def test_나무_오른쪽_클릭은_메뉴_항목을_밖에서_주는_자리(): + tree = (UI / "ui_template_tree.ts").read_text(encoding="utf-8") + assert "menu?: () => TreeMenuItem[]" in tree and "export function openTreeMenu(" in tree + assert '"contextmenu"' in tree + picker = (UI / "ui_template_item_picker.ts").read_text(encoding="utf-8") + # 새 항목 메뉴는 개인 그룹 · 폴더에만(마스터 · 회사는 메뉴 없음) · 프로젝트 안에서만 + assert 'folder.layer !== "personal" || !opts.projectId' in picker + assert "menu: menuOf(node)" in picker + + +def test_B05_구조물_폼이_구조물_id_를_모달에_넘김(): + panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text( + encoding="utf-8" + ) + assert "pipeStructureId(selectedPipeChainage)" in panel and "editingId ??" in panel diff --git a/resources/tester/test_route_initial.py b/resources/tester/test_route_initial.py index 6d5300a4..2f684022 100644 --- a/resources/tester/test_route_initial.py +++ b/resources/tester/test_route_initial.py @@ -29,6 +29,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, stat from common_util.common_util_route_polyline import build_planned_polyline SEARCHED = [mode for mode in MODES if mode != "follow"] +# 제약을 지키는 갈래(후보 묶음에서 고름) — 비중은 제약을 벌점으로만(PLAN 23-4a) +BRANCHES = ["earthwork", "gentle", "short", "balance"] GRADE = 0.14 RADIUS = 12.0 @@ -76,7 +78,7 @@ def hill() -> Terrain: ) -@pytest.mark.parametrize("mode", SEARCHED) +@pytest.mark.parametrize("mode", BRANCHES) def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None: result = _run(hill, mode) _kept(result) @@ -105,12 +107,24 @@ def _earth(result: dict) -> float: return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"] -def test_each_branch_best_at_own_goal(hill: Terrain) -> None: - """토공 최소 = 절성토 가장 적음 · 기울기 순한 = 최대 기울기 가장 낮음(같은 후보 묶음).""" - runs = {mode: _run(hill, mode) for mode in SEARCHED} - assert all(_earth(runs["earthwork"]) <= _earth(r) + 1e-6 for r in runs.values()) +def _check_branch_goals(runs: dict) -> None: + """토공 최소 = 절성토 · 기울기 순한 = 최대 기울기 · 짧은 노선 = 연장 · 흙 균형 = |절토 − 성토| + 가 갈래(같은 후보 묶음) 가운데 가장 좋음 · 흙 남음 = 절토 − 성토.""" + branches = [runs[mode] for mode in BRANCHES] + assert all(_earth(runs["earthwork"]) <= _earth(r) + 1e-6 for r in branches) top = runs["gentle"]["metrics"]["max_grade_pct"] - assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in runs.values()) + assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in branches) + shortest = runs["short"]["metrics"]["length_m"] + assert all(shortest <= r["metrics"]["length_m"] + 1e-6 for r in branches) + surplus = abs(runs["balance"]["metrics"]["surplus_m3"]) + assert all(surplus <= abs(r["metrics"]["surplus_m3"]) + 0.1 for r in branches) + for result in runs.values(): + metrics = result["metrics"] + assert abs(metrics["surplus_m3"] - (metrics["cut_m3"] - metrics["fill_m3"])) <= 0.11 + + +def test_each_branch_best_at_own_goal(hill: Terrain) -> None: + _check_branch_goals({mode: _run(hill, mode) for mode in MODES}) # 비중 끝값 · 단조(PLAN 23-3e) — 예상 · 토공 · 기울기 한쪽 100 이면 그 갈래와 같은 선 @@ -144,7 +158,8 @@ def _check_weight_ends(run) -> None: def _check_weight_monotone(run) -> None: - """비중을 한쪽으로 옮기면 그 목표 값은 나빠지지 않음(후보가 같으니 가중합 최소의 성질).""" + """예상 몫을 줄이면 예상노선 이격이 늘기만 함(비중 탐색 = 가중합 최소의 성질) · 토공 ↔ 기울기 + 끝값(갈래)은 그 사이 어느 비중보다 제 목표에서 좋음(쓸기가 후보 묶음에 들어 있음).""" def metrics(steps): keys = ("expected", "earthwork", "grade") @@ -155,8 +170,8 @@ def _check_weight_monotone(run) -> None: earth = metrics(TOWARD_EARTH) cuts = [m["cut_m3"] + m["fill_m3"] for m in earth] tops = [m["max_grade_pct"] for m in earth] - assert cuts == sorted(cuts, reverse=True), cuts - assert tops == sorted(tops), tops + assert cuts[-1] <= min(cuts) + 1e-6, cuts # 끝 0/100/0 = 토공 최소 + assert tops[0] <= min(tops) + 1e-6, tops # 끝 0/0/100 = 기울기 순한 def test_weight_ends_match_branches(hill: Terrain) -> None: @@ -185,7 +200,7 @@ def test_stream_offset_is_hard() -> None: """평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 모든 갈래가 버퍼를 비켜 감.""" flat = _terrain(lambda xx, yy: np.zeros_like(xx)) stream = [[(0.0, 220.0), (800.0, 220.0)]] - for mode in SEARCHED: + for mode in BRANCHES: result = _run(flat, mode, streams=stream) _kept(result) assert result["metrics"]["over_limit"]["stream"] == 0 @@ -203,7 +218,7 @@ def test_stream_crossing_is_allowed() -> None: def test_terrain_impossible_is_listed() -> None: """복도 전체를 가로지르는 20 m 벽 — 어떤 길도 상한을 넘음 → 목록 · 사유 · 막은 기울기.""" wall = _terrain(lambda xx, yy: np.where(xx > 400.0, 20.0, 0.0)) - result = _run(wall, "weighted") + result = _run(wall, "earthwork") _kept(result) assert result["relaxed"] is True assert result["terrain_bound_pct"] > GRADE * 100.0 @@ -222,7 +237,8 @@ def test_grade_line_stays_within_limit() -> None: def test_weights_and_stream_filter() -> None: assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0 - assert set(MODES) == {"follow", "earthwork", "gentle", "weighted"} + assert set(MODES) == {"follow", "earthwork", "gentle", "weighted", "short", "balance"} + assert resolve_weights("short", {"expected": 99})["expected"] == 0.0 line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]} features = [ {"properties": {"구분": "세류"}, "geometry": line}, @@ -302,6 +318,8 @@ def test_request_accepts_logic_name() -> None: assert RouteInitialRequest.model_validate({"logic": "gentle"}).mode == "gentle" assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork" + assert RouteInitialRequest.model_validate({"logic": "short"}).mode == "short" + assert RouteInitialRequest.model_validate({"logic": "balance"}).mode == "balance" replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}}) assert replan.initial is not None and replan.initial.mode == "weighted" @@ -385,20 +403,39 @@ def test_project_follow_is_preprocess_initial(project_runs: dict) -> None: def test_project_branches_differ_by_goal(project_runs: dict) -> None: - """갈래끼리 선이 수 m 이상 갈리고 · 각 갈래가 제 목표 비교값에서 가장 좋음.""" - searched = {mode: project_runs[mode] for mode in SEARCHED} # 비중은 50/25/25 - for first in SEARCHED: - for second in SEARCHED: - if first < second: - gap = LineString(searched[first]["planned"]).hausdorff_distance( - LineString(searched[second]["planned"]) - ) - assert gap >= 3.0, (first, second, gap) - assert all(_earth(searched["earthwork"]) <= _earth(r) for r in searched.values()) - top = searched["gentle"]["metrics"]["max_grade_pct"] - assert all(top <= r["metrics"]["max_grade_pct"] for r in searched.values()) - for result in searched.values(): - assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"]) + """각 갈래가 제 목표 비교값에서 가장 좋음 · 토공 최소와 기울기 순한은 선이 갈림(6.1 m).""" + _check_branch_goals({mode: project_runs[mode] for mode in MODES}) + assert _gap(project_runs["earthwork"], project_runs["gentle"]) >= 3.0 + for mode in MODES: + metrics = project_runs[mode]["metrics"] + assert metrics["over_limit"]["total"] == len(metrics["violations"]) + + +def test_project_similar_and_band(project_runs: dict) -> None: + """가파른 지형 — 제약 안 길이 한 띠(띠 폭 < 20 m) · 10 m 안인 다른 갈래를 `similar` 로 알림.""" + earthwork = project_runs["earthwork"] + assert earthwork["band_width_m"] < 20.0 + near = {item["logic"]: item["max_gap_m"] for item in earthwork["similar"]} + assert near["gentle"] == round(_gap(earthwork, project_runs["gentle"]), 1) + assert all(gap < 10.0 for gap in near.values()) and "earthwork" not in near + assert "follow" not in near # 추적은 40 m 넘게 떨어짐 + + +def test_project_expected_axis_moves_little_by_little(project_runs: dict) -> None: + """예상 100 → 0(토공 : 기울기 1 : 1) — 99 는 추적에서 몇 m 안 · 이웃 단계마다 노선이 움직임 · + 추적에서 점점 멀어짐(이격 늘기만) · 비중 탐색은 넘은 곳을 「비중 계산」 사유로 알림.""" + run = project_runs["run"] + near = run("weighted", {"expected": 99, "earthwork": 0.5, "grade": 0.5}) + assert 0.0 < _gap(near, project_runs["follow"]) < 10.0 + steps = [ + run("weighted", {"expected": e, "earthwork": (100 - e) / 2, "grade": (100 - e) / 2}) + for e in range(100, -1, -10) + ] + assert all(_gap(a, b) > 0.0 for a, b in zip(steps, steps[1:])) + offsets = [step["metrics"]["offset_avg_m"] for step in steps] + assert offsets == sorted(offsets), offsets + reasons = [v["reason"] for v in steps[5]["metrics"]["violations"] if v["kind"] == "grade"] + assert reasons and all(reason.startswith("비중 계산") for reason in reasons) def test_project_weight_ends_match_branches(project_runs: dict) -> None: diff --git a/resources/tester/ui/item-picker-data.test.ts b/resources/tester/ui/item-picker-data.test.ts index be8ffa93..37f0c5db 100644 --- a/resources/tester/ui/item-picker-data.test.ts +++ b/resources/tester/ui/item-picker-data.test.ts @@ -10,6 +10,7 @@ import { isCurrent, itemName, pathIds, + pathOf, rowId, rowTip, shown, @@ -89,13 +90,8 @@ assert.equal( "옹벽 H=2m", ); const placeholderTree = buildTree([fresh], labels); -assert.equal(placeholderTree[2].children?.[0].label, "분류 없음"); // 종류 -assert.equal(placeholderTree[2].children?.[0].children?.[0].label, "분류 없음"); // 분류1 -assert.deepEqual(pathIds(fresh, labels), [ - "personal", - "personal|분류 없음", - "personal|분류 없음|분류 없음", -]); +assert.equal(placeholderTree[2].children?.[0].label, "n1"); // 경로가 비면 폴더 없이 그룹 바로 밑 잎 +assert.deepEqual(pathIds(fresh, labels), ["personal"]); // 툴팁 = 층 이름 + 키 assert.equal(rowTip(rows[7], labels), "개인 · SS000201"); @@ -123,14 +119,38 @@ assert.deepEqual( const cat = system.children?.[0].children?.[0]; assert.equal(cat?.label, "메쌓기"); assert.equal(cat?.count, 2); -const leaf = cat?.children?.[0]; +const size = cat?.children?.[0]; // 폴더 깊이 자유 — 경로 앞부분이 모두 폴더 +assert.equal(size?.label, "1.2m"); +assert.deepEqual(size?.folder, { layer: "system", path: ["돌기슭막이", "메쌓기", "1.2m"] }); +assert.deepEqual(tree[2].folder, { layer: "personal", path: [] }); // 그룹 줄 = 빈 경로 +const leaf = size?.children?.[0]; assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유"); assert.equal(leaf?.tip, "마스터 · SS000001"); // 툴팁 = 층 이름 + 키 assert.equal(leaf?.count, null); assert.ok(leaf?.row); -// 분류가 빈 항목은 「분류 없음」 한 칸에 모임 -assert.equal(tree[1].children?.[1].children?.[0].label, "분류 없음"); +// 경로가 짧은 항목은 얕은 폴더에(옹벽 H=2m = 폴더 옹벽 · 잎 「옹벽 H=2m」) +assert.equal(tree[1].children?.[1].label, "옹벽"); +assert.equal(tree[1].children?.[1].children?.[0].label, "옹벽 H=2m"); +assert.equal(tree[1].children?.[1].children?.[0].folder, undefined); // 잎에는 folder 없음 + +// 서버가 준 `경로` 가 종류 · 분류보다 앞 — 이름 = 경로 이음 · 자리표는 뺌 +const deep = row("SS000400", "d1", "무시", ["무시", "", ""], "personal", { + 경로: ["가", "나", "다", "라", "마"], +}); +assert.deepEqual(pathOf(deep), ["가", "나", "다", "라", "마"]); +assert.equal(itemName(deep), "가 나 다 라 마"); +assert.deepEqual(pathIds(deep), [ + "personal", + "personal|가", + "personal|가|나", + "personal|가|나|다", + "personal|가|나|다|라", +]); +assert.deepEqual(pathOf({ key: "k", 열: "x", 경로: ["새 분류1", "옹벽"] }), ["옹벽"]); +const deepTree = buildTree([deep], labels)[2]; +assert.equal(deepTree.children?.[0].children?.[0].children?.[0].children?.[0].label, "라"); +assert.equal(deepTree.count, 1); // 빈 그룹도 한 줄은 남음(회사 없는 사람의 회사 · 개인 그룹) const onlySystem = buildTree( @@ -198,6 +218,7 @@ assert.deepEqual(pathIds(rows[0], labels), [ "system", "system|돌기슭막이", "system|돌기슭막이|메쌓기", + "system|돌기슭막이|메쌓기|1.2m", ]); // 폴더 모양 — 다섯 줄 · 이름은 서버 names · 없으면 키만(작은 글) · 둘 다 없으면 빈 키 diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index 4d8d73dd..44852a47 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -242,3 +242,30 @@ overflow: auto; } } + +/* 구조물 복사본 상태 줄 — 고침 · 마스터가 바뀜 · 원본 없음 표시 */ +.ui-item-picker__badge { + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text); +} + +.ui-item-picker__badge.is-warn { + border-color: var(--color-warning); + color: var(--color-text); + font-weight: 600; +} + +/* [적용] 뒤 구조물 [저장] 안내 한 줄 — 단추 왼쪽 */ +.ui-item-picker__saveline { + margin-right: auto; + align-self: center; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 글 묻는 작은 창(개인에 저장 · 새 항목) */ +.ui-modal.ui-item-ask { + width: min(28rem, 92vw); +} diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index 5baec0eb..4062fbd8 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -3,8 +3,11 @@ * 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름. * 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0). * - * const result = await openItemPicker({ current, canCopy }); + * const result = await openItemPicker({ current, canCopy, projectId, structureId }); * result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel" + * + * 26-3b — 카드 · 편집 · [재적용] · [개인에 저장] 은 **고르는 구조물의 복사본**(`structureId`) 기준. + * 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정). * ========================================================================== */ import "./ui_template_item_picker.css"; @@ -27,7 +30,9 @@ import { } from "./ui_template_item_picker_data"; import { t } from "./ui_template_locale"; import { openModal } from "./ui_template_modal"; -import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree"; +import { createTreeRow, renderTree, type TreeMenuItem, type TreeNode } from "./ui_template_tree"; +import { askFields } from "./ui_template_item_picker_ask"; +import { projectItems, type CreatedItem } from "./ui_template_item_picker_project"; import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; import { createEditView, type EditView } from "./ui_template_item_picker_edit"; @@ -40,6 +45,8 @@ export interface ItemPickerOptions { canCopy: boolean; /** 프로젝트 안에서 부르면 그 id — 카드 [수정] 이 프로젝트 복사본을 고침(없으면 원본 보기만 · 25-2d) */ projectId?: string | null; + /** 고르는 구조물의 id(`structure_id` · 관 지점 `pipe@측점`) — 복사본이 이 구조물 것 · 없으면(추가 전) 원본 보기만 */ + structureId?: string | null; } export type ItemPickerResult = @@ -88,6 +95,23 @@ async function copyToPersonal(row: PickerRow): Promise { return body?.key ?? null; } +const NEW_TEXT = { + menu: "새 항목", + title: "새 항목", + note: "{value} 안에 새 항목을 만듦 — 개인 층에도 저장되고 이 구조물에 지정됨", + name: "이름", + unit: "단위", + ok: "만들기", + cancel: "취소", + empty: "{label} 을(를) 넣을 것", + made: "새 항목을 만들어 지정했음 — 구조물 [저장]을 눌러야 이 구조물 복사본이 남음", + failed: "새 항목을 만들지 못함 — {value}", + noStructure: "구조물을 먼저 추가해야 새 항목을 이 구조물에 지정할 수 있음", + saveLine: "[적용] 뒤 구조물 [저장]을 눌러야 이 구조물 복사본이 남음", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + const message = (error: unknown): string => error instanceof Error ? error.message : String(error); @@ -106,7 +130,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise openEdit(request), + // 개인에 저장 — 나무를 다시 받고 새 항목을 고름(개인 그룹에 뜸) + onCreated: (created) => void load(created.key), }); const cols = el("div", { className: "ui-item-picker__cols", @@ -147,11 +174,25 @@ export function openItemPicker(opts: ItemPickerOptions): Promise TreeMenuItem[]) | undefined => { + const folder = node.folder; + if (!folder || folder.layer !== "personal" || !opts.projectId) return undefined; + return () => [ + { + label: NEW_TEXT.menu, + run: () => void createNew(folder.path), + disabledReason: opts.structureId ? undefined : NEW_TEXT.noStructure, + }, + ]; + }; + const toTree = (node: PickNode, searching: boolean): TreeNode => ({ id: node.id, label: node.label, count: node.count, tip: node.tip, + menu: menuOf(node), open: searching || opened.has(node.id), run: node.row ? () => choose(node.row as PickerRow) : undefined, children: node.children?.map((child) => toTree(child, searching)), @@ -234,9 +275,48 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { + if (!opts.projectId || !opts.structureId) return; + const where = [labels().layers.personal, ...path].join(" › "); + const got = await askFields({ + title: NEW_TEXT.title, + note: fill(NEW_TEXT.note, where), + fields: [ + { key: "name", label: NEW_TEXT.name, required: true }, + { key: "unit", label: NEW_TEXT.unit, placeholder: "m · ㎥ · 개소" }, + ], + okLabel: NEW_TEXT.ok, + cancelLabel: NEW_TEXT.cancel, + closeLabel: t("M02_Close"), + emptyText: NEW_TEXT.empty, + }); + if (!got) return; + try { + const made: CreatedItem = await projectItems(opts.projectId).newItem( + opts.structureId, + path, + got.name, + got.unit, + ); + showToast(NEW_TEXT.made, "success"); + finish({ action: "apply", item: made.summary_item as SummaryItem }); + } catch (error) { + showToast(fill(NEW_TEXT.failed, message(error)), "error"); // 회사 없음 409 = 「회사부터 등록」 그대로 + } + } + const actions = el("div", { className: "ui-item-picker__actions", - children: [copyButton, manageButton, clearButton, applyButton], + children: [ + ...(opts.projectId + ? [el("span", { className: "ui-item-picker__saveline", text: NEW_TEXT.saveLine })] + : []), + copyButton, + manageButton, + clearButton, + applyButton, + ], }); /** 카드 [수정] — 모달 안에서 편집 화면으로 바뀜(나무 · 카드 · 단추는 숨김) */ @@ -250,12 +330,13 @@ export function openItemPicker(opts: ItemPickerOptions): Promise | null> { + return new Promise((resolve) => { + let done = false; + const finish = (value: Record | null): void => { + if (done) return; + done = true; + resolve(value); + }; + openModal({ + title: opts.title, + closeLabel: opts.closeLabel, + dialogClass: "ui-item-ask", + backdropClass: "ui-item-ask__backdrop", + beforeClose: () => { + finish(null); + return true; + }, + mount: (body, close) => { + const inputs = opts.fields.map((field) => ({ + field, + input: createInputField({ + label: field.label, + value: field.value ?? "", + placeholder: field.placeholder, + }), + })); + const submit = (): void => { + let bad = false; + for (const { field, input } of inputs) { + const empty = field.required && input.input.value.trim() === ""; + input.setError(empty ? opts.emptyText.replace("{label}", field.label) : undefined); + bad ||= !!empty; + } + if (bad) return; + finish( + Object.fromEntries( + inputs.map(({ field, input }) => [field.key, input.input.value.trim()]), + ), + ); + close(); + }; + for (const { input } of inputs) + input.input.addEventListener("keydown", (event) => { + if (event.key === "Enter") submit(); + }); + body.append( + ...(opts.note ? [el("p", { className: "ui-item-picker__where", text: opts.note })] : []), + ...inputs.map(({ input }) => input.root), + el("div", { + className: "ui-item-picker__actions", + children: [ + createButton({ + label: opts.cancelLabel, + variant: "ghost", + onClick: () => { + finish(null); + close(); + }, + }), + createButton({ label: opts.okLabel, variant: "filled", onClick: submit }), + ], + }), + ); + }, + }); + }); +} diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index 7abeeda4..482a3b23 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -1,12 +1,14 @@ /* ============================================================================= * ui_template_item_picker_detail.ts - * 항목 고르기 모달 오른쪽 상세(계획서 25-2d) — 글 목록이던 것을 **카드 넷**(도면 · 면적산출 · 재료산출 · - * 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀 + * 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b) — **카드 넷**(도면 · 면적산출 · 재료산출 · 선택 입력). + * 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀 * (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). * - * 자료 자리 — 프로젝트에 가져온 항목은 **프로젝트 복사본**을 읽고, 아직이면 **원본 층**을 읽음(읽기만). - * [수정] 은 복사본만 고침 — 안 가져온 항목은 「프로젝트로 가져와 고침」(미리 가져오기 뒤 편집). - * 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈. 편집 화면은 `ui_template_item_picker_edit.ts`. + * 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고, + * 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」 + * (미리 가져오기 뒤 편집). [재적용] = 원본을 다시 가져옴(덮이는 것을 확인 창으로 알림) · + * [개인에 저장] = 이 구조물 복사본을 개인 층 새 항목으로. 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈 + * (개인에 저장 · 새 항목은 개인 층에 **새 항목**을 더할 뿐). 편집 화면은 `ui_template_item_picker_edit.ts`. * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; @@ -19,20 +21,23 @@ import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_Master import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; import type { ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; +import { askFields } from "./ui_template_item_picker_ask"; import { folderLines, itemName, layerOf, + pathOf, rowTip, - shown, type PickerLayer, type PickerRow, } from "./ui_template_item_picker_data"; import { - folderOf, loadInputs, PART_OF, projectItems, + sameItem, + structureFolder, + type CreatedItem, type ProjectItemInfo, type ProjectItems, } from "./ui_template_item_picker_project"; @@ -49,10 +54,14 @@ export interface EditRequest { export interface ItemDetailOptions { /** 프로젝트 안에서 열렸으면 그 id — 없으면 원본 보기만(고침 없음) */ projectId: string | null; + /** 고르는 구조물의 id(`structure_id` · 관 지점 `pipe@측점`) — 아직 추가 전이면 null = 복사본 없음 */ + structureId: string | null; layerLabels: Record; hint: string; unit: string; onEdit: (request: EditRequest) => void; + /** [개인에 저장] 으로 개인 층에 새 항목이 생김 — 나무를 다시 받고 그 항목을 고름 */ + onCreated: (created: CreatedItem) => void; } export interface ItemDetail { @@ -60,24 +69,56 @@ export interface ItemDetail { show: (row: PickerRow | null) => void; /** 편집을 마치고 돌아옴 — 지금 항목을 다시 읽음 */ reload: () => void; + /** 이 구조물 복사본에 고침이 생김 — 목록을 다시 받지 않고 표시만 */ + markEdited: () => void; } const TEXT = { - importEdit: "프로젝트로 가져와 고침", - notImported: "프로젝트에 아직 안 가져옴 — 원본을 보고 있음", - imported: "프로젝트에 가져옴 {value}", - changed: "마스터가 바뀜 — 프로젝트 복사본은 가져온 때 그대로", - refresh: "새로 가져오기", - refreshAsk: "프로젝트 복사본의 고친 내용이 원본으로 덮임 — 새로 가져올까요?", - importFailed: "프로젝트로 가져오지 못함 — {value}", - refreshFailed: "새로 가져오지 못함 — {value}", + importEdit: "이 구조물로 가져와 고침", + noStructure: + "구조물을 먼저 추가하면 이 항목을 그 구조물로 가져와 고칠 수 있음 — 지금은 원본을 보고 있음", + notImported: "이 구조물에는 아직 안 가져옴 — 원본을 보고 있음", + otherItem: "이 구조물의 복사본은 「{value}」 — [수정]하면 이 항목으로 바뀜", + otherEdited: + "이 구조물의 복사본 「{value}」 에서 고친 것이 있음 — 이 항목으로 바꾸면 그 고침은 _지난 폴더로 옮겨짐. 바꿀까요?", + imported: "이 구조물 복사본 · 가져옴 {value}", + edited: "고침 있음", + changed: "마스터가 바뀜 — 복사본은 가져온 때 그대로", + gone: "원본이 없어짐 — 복사본은 그대로 씀", + reapply: "재적용", + reapplyTip: + "원본을 다시 가져옴 — 이 구조물 복사본에서 고친 것(도면 · 시트 · 선택 입력 자리)이 원본 것으로 덮임", + reapplyAsk: + "원본을 다시 가져옴 — 이 구조물 복사본에서 고친 도면 · 면적산출 · 재료산출 · 선택 입력 자리가 원본 것으로 덮임(넣은 값은 같은 키면 남음). 재적용할까요?", + reapplied: "재적용했음 — 덮인 것: {value}", + reappliedNone: "재적용했음 — 덮인 것 없음(원본과 같았음)", + reapplyFailed: "재적용하지 못함 — {value}", + saveAs: "개인에 저장", + saveAsTip: "이 구조물 복사본을 내 개인 층의 새 항목으로 저장 — 단가 · 넣은 값은 안 가져감", + saveAsTitle: "개인에 저장", + saveAsNote: "이 구조물 복사본을 개인 층 새 항목으로 저장함 — 경로는 폴더 이름을 「/」 로 이음", + pathLabel: "경로(폴더)", + nameLabel: "이름", + saved: "개인 층에 저장했음", + saveFailed: "개인에 저장하지 못함 — {value}", + ok: "확인", + cancel: "취소", + close: "닫기", + empty: "{label} 을(를) 넣을 것", + importFailed: "이 구조물로 가져오지 못함 — {value}", listFailed: "프로젝트 항목을 못 읽음 — {value}", calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개", calcStuck: " · 멈춘 로직 {stuck}개", - loading: "읽는 중…", noCopy: "복사본에 없음", combo: "자재조합", price: "일위대가", + parts: { + 도면: "도면", + 면적: "면적산출", + 재료: "재료산출", + 자재조합: "자재조합", + 입력연결: "선택 입력 자리", + } as Record, }; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); @@ -97,14 +138,19 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { let cards: ItemCardsHandle | null = null; let inputs: InputsValueHandle | null = null; let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; + /** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */ let info: ProjectItemInfo | null = null; + let other: ProjectItemInfo | null = null; const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null; + const folder = opts.structureId ? structureFolder(opts.structureId) : null; + /** 복사본을 고칠 수 있나 — 프로젝트 안 · 구조물이 있을 때 */ + const canCopy = !!items && !!folder; const small = (text: string): HTMLElement => el("p", { className: "ui-item-picker__where", text }); const paintHead = (row: PickerRow): void => { - const where = [opts.layerLabels[layerOf(row)], shown(row.종류), shown(row.분류1)] + const where = [opts.layerLabels[layerOf(row)], ...pathOf(row).slice(0, -1)] .filter(Boolean) .join(" › "); const pairs = folderLines(row).filter( @@ -126,7 +172,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { ); }; - /** 프로젝트 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */ + /** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */ const makeSource = (row: PickerRow): ItemSource => info && opts.projectId ? { @@ -144,20 +190,28 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { read: (part, column) => readTemplate(layerOf(row), part, column, null), }; - const paintStatus = async (row: PickerRow, mine: number): Promise => { + const badge = (text: string, className = ""): HTMLElement => + el("span", { className: `ui-item-picker__badge ${className}`.trim(), text }); + + const paintStatus = (row: PickerRow, mine: number): void => { if (!items) return void (status.hidden = true); const nodes: (HTMLElement | string)[] = []; - if (!info) nodes.push(TEXT.notImported); + if (!folder) nodes.push(TEXT.noStructure); + else if (other) nodes.push(fill(TEXT.otherItem, other.이름 ?? other.원본.key)); + else if (!info) nodes.push(TEXT.notImported); else { nodes.push(fill(TEXT.imported, dateOf(info.가져온날))); - if (info.마스터바뀜) { - const button = createButton({ label: TEXT.refresh, variant: "ghost", size: "sm" }); - button.addEventListener("click", () => void refresh(row)); - nodes.push( - el("strong", { className: "ui-item-picker__changed", text: TEXT.changed }), - button, - ); - } + if (info.고침) nodes.push(badge(TEXT.edited)); + if (info.원본없음) nodes.push(badge(TEXT.gone, "is-warn")); + else if (info.마스터바뀜) nodes.push(badge(TEXT.changed, "is-warn")); + const reapply = createButton({ label: TEXT.reapply, variant: "ghost", size: "sm" }); + reapply.title = TEXT.reapplyTip; + reapply.disabled = !!info.원본없음; + reapply.addEventListener("click", () => void reapplyNow(row)); + const saveAs = createButton({ label: TEXT.saveAs, variant: "ghost", size: "sm" }); + saveAs.title = TEXT.saveAsTip; + saveAs.addEventListener("click", () => void saveAsNow(row)); + nodes.push(reapply, saveAs); } status.replaceChildren(...nodes); status.hidden = false; @@ -179,25 +233,66 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { ); } - async function refresh(row: PickerRow): Promise { - if (!items || !info || !(await showConfirmDialog(TEXT.refreshAsk))) return; + /** 목록은 모달에서 한 번만 받고 바뀐 줄만 갈아 끼움 */ + const upsert = (next: ProjectItemInfo): void => { + infos = [...(infos ?? []).filter((one) => one.폴더 !== next.폴더), next]; + }; + + async function reapplyNow(row: PickerRow): Promise { + if (!items || !info || !(await showConfirmDialog(TEXT.reapplyAsk, TEXT.reapply))) return; try { - const fresh = await items.refresh(info); - infos = (infos ?? []).map((one) => (one.폴더 === fresh.폴더 ? fresh : one)); + const got = await items.reapply(info); + upsert(got.info); + const names = got.covered.map((part) => TEXT.parts[part] ?? part); + showToast( + names.length ? fill(TEXT.reapplied, names.join(" · ")) : TEXT.reappliedNone, + "success", + ); show(row); } catch (error) { - showToast(fill(TEXT.refreshFailed, why(error)), "error"); + showToast(fill(TEXT.reapplyFailed, why(error)), "error"); } } - /** [수정] — 안 가져온 항목이면 먼저 프로젝트로 가져옴 */ + async function saveAsNow(row: PickerRow): Promise { + if (!items || !info) return; + const got = await askFields({ + title: TEXT.saveAsTitle, + note: TEXT.saveAsNote, + fields: [ + { key: "path", label: TEXT.pathLabel, value: pathOf(row).slice(0, -1).join("/") }, + { key: "name", label: TEXT.nameLabel, value: itemName(row), required: true }, + ], + okLabel: TEXT.ok, + cancelLabel: TEXT.cancel, + closeLabel: TEXT.close, + emptyText: TEXT.empty, + }); + if (!got) return; + try { + const path = got.path + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + const created = await items.saveAs(info.폴더, path, got.name); + showToast(TEXT.saved, "success"); + opts.onCreated(created); + } catch (error) { + showToast(fill(TEXT.saveFailed, why(error)), "error"); // 회사 없음 409 등 서버 까닭 그대로 + } + } + + /** [수정] — 이 구조물에 아직 없으면(또는 다른 항목이면) 먼저 이 구조물로 가져옴 */ async function edit(kind: EditKind | "inputs"): Promise { const row = current; - if (!row || !items || !opts.projectId) return; + if (!row || !items || !opts.projectId || !opts.structureId) return; if (!info) { + const ask = other?.고침 ? fill(TEXT.otherEdited, other.이름 ?? other.원본.key) : null; + if (ask && !(await showConfirmDialog(ask))) return; try { - info = await items.importItem(row); - infos = [...(infos ?? []), info]; + info = await items.importItem(row, opts.structureId); + other = null; + upsert(info); } catch (error) { showToast(fill(TEXT.importFailed, why(error)), "error"); return; @@ -234,7 +329,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") }); cards = cardModule.createItemCards({ source: () => source, - editable: () => !!items, + editable: () => canCopy, onEdit: (kind) => void edit(kind), warnOnFail: () => false, extraCards: [inputs.root], @@ -256,25 +351,34 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { paintHead(row); void (async () => { try { - if (items) infos ??= await items.list(); + if (items && folder) infos ??= await items.list(); } catch (error) { if (mine === seq) showToast(fill(TEXT.listFailed, why(error)), "error"); infos = []; } if (mine !== seq) return; - info = infos?.find((one) => one.폴더 === folderOf(row)) ?? null; + const own = infos?.find((one) => one.폴더 === folder) ?? null; + info = own && sameItem(own, row) ? own : null; + other = own && !info ? own : null; source = makeSource(row); await ensureCards(); if (mine !== seq || !cards || !inputs) return; - const label = info ? null : TEXT.importEdit; + const label = !info && canCopy ? TEXT.importEdit : null; cards.relabel({ structure: label, area: label, material: label }); inputs.setEditLabel(label ?? "선택 입력 수정"); cards.root.hidden = false; - void paintStatus(row, mine); + paintStatus(row, mine); void cards.load({ id: row.열, label: itemName(row) }); void paintInputs(row, mine); })(); } - return { root, show, reload: () => show(current) }; + return { + root, + show, + reload: () => show(current), + markEdited: () => { + if (info) upsert({ ...info, 고침: true }); + }, + }; } diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index abe7996c..9aa0b4b6 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -48,10 +48,11 @@ const TEXT = { const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const fill = (text: string, value: string): string => text.replace("{value}", value); -export function createEditView(req: EditRequest, onBack: () => void): EditView { +export function createEditView(req: EditRequest, onBack: (saved: boolean) => void): EditView { const { kind, row, info, items } = req; let dirty = false; let saving = false; + let saved = false; // 한 번이라도 저장했나 — 돌아갈 때 「고침 있음」 표시 let doc: unknown = null; let drawing: DrawingTemplateHandle | null = null; let sheet: SpreadsheetHandle | null = null; @@ -191,6 +192,7 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { doc = merged; } setDirty(false); + saved = true; showToast(TEXT.saved, "success"); } catch (error) { showToast(fill(TEXT.saveFailed, why(error)), "error"); @@ -211,7 +213,7 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { save.addEventListener("click", () => void doSave()); back.addEventListener("click", async () => { - if (await leave()) onBack(); + if (await leave()) onBack(saved); }); return { root, start: () => void start(), isDirty: () => dirty, leave, destroy }; diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index 7275b48a..27366224 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -2,11 +2,13 @@ * ui_template_item_picker_project.ts * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 입력 목록 · 계산을 한 곳에서 부름. * - * 프로젝트 복사본(sub6 · 25-2b) - * GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, …}]} - * POST /api/projects/{id}/items {layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} + * 구조물 복사본(sub6 · 26-1 · 26-2b) — 폴더 = 구조물 id(같은 항목을 고른 구조물도 복사본은 따로) + * GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, 원본없음, …}]} + * POST /api/projects/{id}/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} + * POST /api/projects/{id}/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) → {…, summary_item} * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) - * POST …/{폴더}/refresh 새로 가져오기(입력값은 같은 키면 살림) + * POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]} + * POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item} * POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력) * 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks` * ========================================================================== */ @@ -42,9 +44,27 @@ export interface ProjectItemInfo { 고침?: boolean; } -/** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */ -export const folderOf = (row: PickerRow): string => - `${layerOf(row)}_${(ownerOf(row) ?? "none").replace(/\//g, "-")}_${row.key}`; +/** 구조물 id → 복사본 폴더 이름 — 서버 `folder_name` 과 같은 글(id 없는 옛 구조물 `#차례` = `n차례`) */ +export const structureFolder = (structureId: string): string => structureId.replace("#", "n"); + +/** 관 지점의 구조물 id — 서버 `pipe@{round(측점, 3)}`(파이썬 float 글: 정수도 `100.0`) */ +export function pipeStructureId(chainageM: number): string { + const rounded = Math.round(chainageM * 1000) / 1000; + return `pipe@${Number.isInteger(rounded) ? rounded.toFixed(1) : String(rounded)}`; +} + +/** 이 복사본이 그 항목의 것인가 — 같은 구조물이 다른 항목으로 바뀌면 원본 참조가 다름 */ +export const sameItem = (info: ProjectItemInfo, row: PickerRow): boolean => + info.원본.layer === layerOf(row) && + (info.원본.owner ?? null) === ownerOf(row) && + info.원본.key === row.key; + +/** 개인 층에 새로 생긴 항목 — `summary_item` 을 폼에 지정하면 됨 */ +export interface CreatedItem { + key: string; + 이름?: string; + summary_item: { layer: string; owner: string | null; key: string; name: string }; +} /** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 */ export interface ProjectCalc { @@ -94,9 +114,18 @@ export interface ProjectItems { /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */ write: (folder: string, part: ProjectPart, doc: unknown) => Promise; /** 프로젝트로 미리 가져옴 */ - importItem: (row: PickerRow) => Promise; - /** 새로 가져옴 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) */ - refresh: (info: ProjectItemInfo) => Promise; + importItem: (row: PickerRow, structureId: string) => Promise; + /** 재적용 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) · `covered` = 덮인 부분 */ + reapply: (info: ProjectItemInfo) => Promise<{ info: ProjectItemInfo; covered: string[] }>; + /** 이 구조물 복사본을 개인 층 새 항목으로 — 못 하면 서버 까닭(회사 없음 409 …) 그대로 던짐 */ + saveAs: (folder: string, path: string[], name: string) => Promise; + /** 프로젝트에서 새 항목(개인 층에도 만들고 그 구조물 복사본으로) */ + newItem: ( + structureId: string, + path: string[], + name: string, + unit: string, + ) => Promise; /** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */ calc: (folder: string) => Promise; } @@ -109,6 +138,10 @@ export function projectItems(projectId: string): ProjectItems { if (kept) return kept; const base = `/projects/${enc(projectId)}/items`; const versions = new Map(); + /** 그 폴더에서 읽어 둔 판을 버림(다시 가져오면 판이 바뀜) */ + const forget = (folder: string): void => { + for (const key of [...versions.keys()]) if (key.startsWith(`${folder}/`)) versions.delete(key); + }; const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`; const client: ProjectItems = { @@ -136,27 +169,50 @@ export function projectItems(projectId: string): ProjectItems { if (reply.status >= 400) throw new Error(reasonOf(reply)); versions.set(key, (reply.body as { 판?: string } | null)?.판 ?? null); }, - importItem: async (row) => { + importItem: async (row, structureId) => { const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key }; - const reply = await call(base, post(ref)); + const reply = await call(base, post({ structure: structureId, ...ref })); if (reply.status >= 300) throw new Error(reasonOf(reply)); const got = reply.body as { 폴더: string; 가져온날?: string | null }; + forget(got.폴더); return { 폴더: got.폴더, 원본: ref, 가져온날: got.가져온날, 마스터바뀜: false }; }, - refresh: async (info) => { - const reply = await call(`${base}/${enc(info.폴더)}/refresh`, post({})); + reapply: async (info) => { + const reply = await call(`${base}/${enc(info.폴더)}/reapply`, post({})); if (reply.status >= 300) throw new Error(reasonOf(reply)); - for (const key of [...versions.keys()]) - if (key.startsWith(`${info.폴더}/`)) versions.delete(key); + forget(info.폴더); inputsCache.clear(); // 원본이 바뀌었을 수 있어 입력 목록도 다시 받음 - const got = reply.body as { 가져온날?: string | null; 원본판?: string | null }; - return { - ...info, - 가져온날: got.가져온날, - 원본판: got.원본판, - 마스터바뀜: false, - 고침: false, + const got = reply.body as { + 가져온날?: string | null; + 원본판?: string | null; + 덮임?: string[]; }; + return { + info: { + ...info, + 가져온날: got.가져온날, + 원본판: got.원본판, + 마스터바뀜: false, + 고침: false, + }, + covered: got.덮임 ?? [], + }; + }, + saveAs: async (folder, path, name) => { + const reply = await call( + `${base}/${enc(folder)}/save-as`, + post({ to: "personal", 경로: path, 이름: name }), + ); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + return reply.body as CreatedItem; + }, + newItem: async (structureId, path, name, unit) => { + const reply = await call( + `${base}/new`, + post({ structure: structureId, 경로: path, 이름: name, 단위: unit }), + ); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + return reply.body as CreatedItem; }, calc: async (folder) => { const reply = await call(`${base}/${enc(folder)}/calc`, post({}));