Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-09-30 08:32:05 +09:00
26 changed files with 1306 additions and 271 deletions
+2 -1
View File
@@ -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 {
+56 -112
View File
@@ -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))]
@@ -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),
@@ -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]))
@@ -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
)
+3 -2
View File
@@ -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
@@ -19,6 +19,8 @@ export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
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<Record<string, unknown>>) {
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(" · ");
@@ -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<RouteInitialLogic, string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다",
earthwork: "절토 · 성토가 가장 적은 노선",
gentle: "종단기울기가 가장 순한 노선",
short: "연장이 가장 짧은 노선(제약 지킴)",
balance: "절토와 성토가 비슷해 남는 흙이 가장 적은 노선",
weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선",
};
@@ -196,6 +207,8 @@ export function openRecommendModal(params: RecommendParams): void {
<div data-line="metrics"></div>
<div data-line="violations" hidden></div>
<div class="is-warn" data-line="relaxed" hidden></div>
<div data-line="similar" hidden></div>
<div class="is-dim" data-line="similar-hint" hidden></div>
<div data-line="criteria"></div>
</div>
<div class="b05-routerec__foot">
@@ -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)
: "";
@@ -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);
@@ -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);
+7 -2
View File
@@ -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 옛 이름(새로 가져오기)
@@ -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,
}),
);
+67 -13
View File
@@ -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("없는폴더");
@@ -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
@@ -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%(비중 계산)"
@@ -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}),
]
@@ -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
+52 -16
View File
@@ -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
+63 -26
View File
@@ -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:
+31 -10
View File
@@ -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 · 없으면 키만(작은 글) · 둘 다 없으면 빈 키
+27
View File
@@ -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);
}
+85 -4
View File
@@ -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<string | null> {
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<ItemPickerResul
layerLabels: labels().layers,
hint: t("M02_PickerHint"),
unit: t("M02_PickerUnit"),
structureId: opts.structureId ?? null,
onEdit: (request) => 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<ItemPickerResul
paintDetail();
};
/** 오른쪽 클릭 — 개인 그룹 · 폴더만 「새 항목」(경로 = 누른 폴더) */
const menuOf = (node: PickNode): (() => 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<ItemPickerResul
}
});
/** 프로젝트에서 새 항목 — 개인 층에도 만들고 돌려받은 `summary_item` 을 폼에 지정 */
async function createNew(path: string[]): Promise<void> {
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<ItemPickerResul
view.start();
}
/** [뒤로] — 선택지로 돌아와 고른 항목을 다시 읽음(고친 값이 카드에 보임) */
function closeEdit(): void {
function closeEdit(saved: boolean): void {
editView?.destroy();
editView = null;
editHost.hidden = true;
cols.hidden = false;
actions.hidden = false;
if (saved) detailView.markEdited();
detailView.reload();
}
@@ -0,0 +1,97 @@
/* =============================================================================
* ui_template_item_picker_ask.ts
* 고르기 모달에서 글을 물을 때 쓰는 작은 입력 창(계획서 26-3b) — [개인에 저장](경로 · 이름) ·
* 나무 오른쪽 클릭 「새 항목」(이름 · 단위). 확인 = 넣은 글들 · 취소 · 닫기 = null.
* ========================================================================== */
import { createButton, createInputField, el } from "./ui_template_elements";
import { openModal } from "./ui_template_modal";
export interface AskField {
key: string;
label: string;
value?: string;
placeholder?: string;
/** 비우면 확인 안 됨 */
required?: boolean;
}
export interface AskOptions {
title: string;
/** 창 위 한 줄 설명 */
note?: string;
fields: AskField[];
okLabel: string;
cancelLabel: string;
closeLabel: string;
/** 빈 값 알림 — `{label}` 자리에 칸 이름 */
emptyText: string;
}
export function askFields(opts: AskOptions): Promise<Record<string, string> | null> {
return new Promise((resolve) => {
let done = false;
const finish = (value: Record<string, string> | 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 }),
],
}),
);
},
});
});
}
+147 -43
View File
@@ -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<PickerLayer, string>;
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<string, string>,
};
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<void> => {
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<void> {
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<void> {
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<void> {
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<void> {
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 });
},
};
}
+4 -2
View File
@@ -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 };
+79 -23
View File
@@ -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<void>;
/** 프로젝트로 미리 가져옴 */
importItem: (row: PickerRow) => Promise<ProjectItemInfo>;
/** 새로 가져옴 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) */
refresh: (info: ProjectItemInfo) => Promise<ProjectItemInfo>;
importItem: (row: PickerRow, structureId: string) => Promise<ProjectItemInfo>;
/** 재적용 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) · `covered` = 덮인 부분 */
reapply: (info: ProjectItemInfo) => Promise<{ info: ProjectItemInfo; covered: string[] }>;
/** 이 구조물 복사본을 개인 층 새 항목으로 — 못 하면 서버 까닭(회사 없음 409 …) 그대로 던짐 */
saveAs: (folder: string, path: string[], name: string) => Promise<CreatedItem>;
/** 프로젝트에서 새 항목(개인 층에도 만들고 그 구조물 복사본으로) */
newItem: (
structureId: string,
path: string[],
name: string,
unit: string,
) => Promise<CreatedItem>;
/** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */
calc: (folder: string) => Promise<ProjectCalc | null>;
}
@@ -109,6 +138,10 @@ export function projectItems(projectId: string): ProjectItems {
if (kept) return kept;
const base = `/projects/${enc(projectId)}/items`;
const versions = new Map<string, string | null>();
/** 그 폴더에서 읽어 둔 판을 버림(다시 가져오면 판이 바뀜) */
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({}));