diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index 4fc9e802f..83781dede 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -40,6 +40,8 @@ export interface RouteInitialRequest { mode: RouteInitialLogic; weights?: RouteInitialWeights; criteria?: RouteInitialCriteria; + /** 추천 점수에 균형 종단(63-4)을 넣을지 — 끄면 원지반만. 안 보내면 켬(서버 기본). */ + with_profile?: boolean; } /** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */ diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index ad9fe1e3b..2ce0afed1 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -64,7 +64,11 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import ( similar, weighted, ) -from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import attach_profile, cover_shortfall +from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import ( + attach_profile, + cover_shortfall, + plain_metrics, +) from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CORRIDOR_M, ROAD_WIDTH_M, @@ -219,6 +223,7 @@ def generate_initial_route( target_grade: float | None = None, design_speed: int = 20, pipes: list[tuple[float, float, float]] | None = None, + with_profile: bool = True, ) -> dict[str, Any]: """갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14). @@ -228,6 +233,7 @@ def generate_initial_route( 후보마다 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 토공 점수를 그것으로 고른다 — 편집 창 추천 점수 · 미리 보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다. + `with_profile` 끄면(종단 반영 끔) 종단 없이 63-4 전 점수(원지반 가늠)로 고른다. """ if mode not in MODES: raise ValueError(f"모르는 갈래: {mode}") @@ -303,12 +309,16 @@ def generate_initial_route( if target == "weighted": outline, metrics = weighted(pool, used, min_radius_m, measure) else: - for route in [*pool.candidates, pool.follow]: - profiled(route) - outline, metrics = pick(target, pool.candidates) - profiled((outline, metrics)) - if pipes and "error" not in metrics["profile"]: - metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes)) + if with_profile: + for route in [*pool.candidates, pool.follow]: + profiled(route) + outline, metrics = pick(target, pool.candidates, with_profile) + if with_profile: + profiled((outline, metrics)) + if pipes and "error" not in metrics["profile"]: + metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes)) + else: + metrics = plain_metrics(metrics) return { "mode": mode, "logic": mode, @@ -321,7 +331,8 @@ def generate_initial_route( "curves": [curve.as_dict() for curve in outline.curves], "metrics": metrics, # 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만 - "similar": None if pool is None else similar(pool, target, outline), + "similar": None if pool is None else similar(pool, target, outline, with_profile), + "with_profile": with_profile, "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), diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index 3161130a1..f09392ef6 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -36,7 +36,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( COST_EARTH_M3, COST_LENGTH_M, ) -from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth +from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CROSSING_BAND, OBJECTIVES, @@ -213,12 +213,15 @@ def build_pool( return pool -def similar(pool: Pool, mode: str, outline: Any) -> list[dict[str, Any]]: +def similar(pool: Pool, mode: str, outline: Any, with_profile: bool = True) -> list[dict[str, Any]]: """이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m). 화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.""" here = LineString(outline.vertices) - others = {"follow": pool.follow, **{name: pick(name, pool.candidates) for name in GOALS}} + others = { + "follow": pool.follow, + **{name: pick(name, pool.candidates, with_profile) for name in GOALS}, + } found = [] for name, (other, _) in others.items(): if name == mode or other is None: @@ -284,7 +287,9 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = { } -def pick(mode: str, candidates: list[Route]) -> Route: - """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보.""" +def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route: + """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보. `with_profile` 끔 = 원지반 가늠 점수.""" goal = GOALS[mode] - return min(candidates, key=lambda item: goal(item[1])) + return min( + candidates, key=lambda item: goal(item[1] if with_profile else plain_metrics(item[1])) + ) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index 869eba157..87b991664 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -366,6 +366,7 @@ def attach_profile( metrics["profile"] = {"error": str(error)} return estimate = metrics["cut_m3"] + metrics["fill_m3"] + metrics["cost_index_base"] = metrics["cost_index"] # 「종단 반영」 끔이 되돌아갈 값 metrics["profile"] = profile metrics["earth_estimate_m3"] = round(estimate, 1) metrics["cost_index"] = round( @@ -376,3 +377,19 @@ def attach_profile( def profile_earth(profile: dict[str, Any]) -> float: """균형 종단 토공 점수 — 절토(자연) + 성토 + 반입 + 사토 ㎥.""" return profile["cut_m3"] + profile["fill_m3"] + profile["borrow_m3"] + profile["spoil_m3"] + + +def plain_metrics(metrics: dict[str, Any]) -> dict[str, Any]: + """균형 종단을 붙이기 전 비교값(복사본) — 「종단 반영」 끔(원지반 가늠 점수). + + 묶음 캐시의 비교값은 종단을 붙인 채 남으므로, 끌 때는 붙인 것을 걷어 낸다.""" + if "profile" not in metrics and "cost_index_base" not in metrics: + return metrics + plain = { + key: value + for key, value in metrics.items() + if key not in ("profile", "earth_estimate_m3", "cost_index_base") + } + if "cost_index_base" in metrics: + plain["cost_index"] = metrics["cost_index_base"] + return plain diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 569e62863..902f5390c 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -118,6 +118,8 @@ class RouteInitialRequest(BaseModel): ] = Field("follow", validation_alias=AliasChoices("logic", "mode")) weights: RouteInitialWeights | None = None criteria: RouteInitialCriteria | None = None + # 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘. + with_profile: bool = True class RouteReplanRequest(BaseModel): diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 020bec581..e748f7c34 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -80,7 +80,9 @@ async def initial_route( terrain, streams = await asyncio.to_thread( load_terrain_and_streams, project_root, selection ) - pipes = await asyncio.to_thread(project_pipes, project_root) + pipes = ( + await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None + ) result = await asyncio.to_thread( functools.partial( generate_initial_route, @@ -96,6 +98,7 @@ async def initial_route( target_grade=None if target_pct is None else target_pct / 100.0, design_speed=design_speed or 20, pipes=pipes, + with_profile=request.with_profile, ) ) except (FileNotFoundError, ValueError) as error: diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index 8c440275e..d12e1c55f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -91,6 +91,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { weights: null as RouteInitialWeights | null, locks: { ...NO_WEIGHT_LOCKS }, }; + /** 추천 「종단 반영」 — 이 편집 창이 사는 동안만 남는다(저장 · 캐시 · [취소] 되돌림 없이 창과 함께 사라짐). */ + const profileMemory = { withProfile: true }; /** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */ let planRead: Promise | null = null; @@ -114,11 +116,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { logic: RouteInitialLogic, weights: RouteInitialWeights | null, criteria: RouteInitialCriteria | null, + withProfile: boolean, ): Promise { const response = await fetchRouteInitial(projectId, { mode: logic, ...(weights ? { weights } : {}), ...(criteria ? { criteria } : {}), + ...(withProfile ? {} : { with_profile: false }), }); return normalizeRouteInitial(response, logic, weights, criteria); } @@ -137,6 +141,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { defaults, weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) }, weightMemory, + profileMemory, planMinRadiusM: params.planMinRadiusM(), expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), current: opening!.planned.map(([x, y]) => [x, y]), diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index dc18035ed..b91155e4b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -45,7 +45,11 @@ import { type WeightKey, } from "./B05_Profile_UI_RouteEdit_Initial_Model"; import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map"; -import { recommendHelpHtml, syncRecommendHelp } from "./B05_Profile_UI_RouteEdit_Recommend_Help"; +import { + bindRecommendHelp, + recommendHelpHtml, + syncRecommendHelp, +} from "./B05_Profile_UI_RouteEdit_Recommend_Help"; import { balanceLine, candidatesTable, @@ -128,6 +132,8 @@ export interface RecommendParams { weights: RouteInitialWeights; /** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */ weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks }; + /** 「종단 반영」 — 켬이 기본(지금 동작). 편집 창이 사는 동안만 남고 저장하지 않는다. */ + profileMemory: { withProfile: boolean }; planMinRadiusM: number; /** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */ expected: Vertex[]; @@ -137,6 +143,7 @@ export interface RecommendParams { logic: RouteInitialLogic, weights: RouteInitialWeights | null, criteria: RouteInitialCriteria | null, + withProfile: boolean, ) => Promise; /** [적용] — 미리 본 노선을 편집 창에 놓는다. */ onApply: (choice: InitialChoice) => void; @@ -211,6 +218,11 @@ export function openRecommendModal(params: RecommendParams): void { +
${CRITERIA_KEYS.map( (key) => `