Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_7

This commit is contained in:
2026-09-29 11:49:41 +09:00
12 changed files with 512 additions and 150 deletions
+11 -1
View File
@@ -2,7 +2,7 @@
* B05_Profile_Api_RouteInitial.ts
* 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다.
*
* POST /projects/{id}/route/initial { mode, weights? } → 노드 · 비교값 · 공통 제약 기준값
* POST /projects/{id}/route/initial { mode, weights?, criteria? } → 노드 · 비교값 · 공통 제약 기준값
*
* 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고,
* 받은 비교값·기준값을 보이기만 한다. 모양은 계획서 20 「API」 줄(서버 `initial_route`) —
@@ -24,9 +24,19 @@ export interface RouteInitialWeights {
grade: number;
}
/** 공통 제약 겹침 값 — 화면 네 칸(계획서 20 확정). 비우면 서버가 프로젝트 기본값을 쓴다.
* 이름은 서버 몸통과 그대로(snake_case) — 변환 없이 그대로 실어 보낸다. */
export interface RouteInitialCriteria {
max_grade_pct?: number;
min_radius_m?: number;
stream_offset_m?: number;
grade_smooth_m?: number;
}
export interface RouteInitialRequest {
mode: RouteInitialLogic;
weights?: RouteInitialWeights;
criteria?: RouteInitialCriteria;
}
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
+58 -24
View File
@@ -23,9 +23,12 @@
· 그래도 길이 없을 때만(지형상 불가능) 벌점 탐색으로 가장 적게 넘긴 길을 내고, 넘긴 곳을
`metrics.violations`(측점 · 값 · 사유)에 적음. `over_limit` 수 = 그 목록 수.
⚠ 사용자 확정 대기 — 아래 임시값과 호출부가 넘기는 종단기울기 상한 · 최소 곡선반지름은 지식DB
(`resources/knowledge/technical_info/01_임도`) 후보일 뿐이다(PLAN 20장 기준값 후보 표).
확정되면 이 머리의 상수만 고친다.
기준값 확정(2026-09-29 사용자 결정, PLAN 20장) — 아래 임시값이 그대로 **기본값**이 됐다
(지식DB `resources/knowledge/technical_info/01_임도` 후보 표 그대로). 종단기울기 상한 ·
최소 곡선반지름은 여전히 호출부(`_road_settings`·`_plan_criteria`)가 프로젝트별로 넘기고,
계류 이격 · 기울기 잴 지반 폭은 이 머리의 상수가 기본값이다 — 넷 모두 화면(`RouteInitialCriteria`)
에서 프로젝트마다 겹쳐 쓸 수 있다(`generate_initial_route` 의 `stream_offset_m`·`grade_smooth_m`
인자, 라우터의 `max_grade`·`min_radius_m` 대입). 응답 `criteria.pending` 은 이제 늘 빈 목록이다.
"""
from __future__ import annotations
@@ -58,14 +61,10 @@ STREAM_CLASSES = ("소하천", "지방하천", "국가하천")
ROAD_WIDTH_M = 4.0
# 탐색 복도 반폭(m) — 예상노선에서 이보다 멀리는 가지 않음(법령 값 아님 · 알고리즘 값).
CORRIDOR_M = 100.0
PENDING = (
"max_grade_pct",
"min_radius_m",
"stream_offset_m",
"road_width_m",
"corridor_m",
"grade_smooth_m",
)
# 기준값 확정(2026-09-29 사용자 결정) — 예전엔 여기 여섯 칸이 「사용자 확정 대기」였다.
# 이제 후보 표 값 그대로 기본값으로 굳었고(화면에서 프로젝트마다 바꿀 수 있음), 빈 목록이 곧
# 「확정됨」이다(`criteria.pending`).
PENDING: tuple[str, ...] = ()
# ── 탐색 · 측정 값 ───────────────────────────────────────────────────────────────
MODES = ("follow", "earthwork", "gentle", "weighted")
@@ -148,20 +147,40 @@ def _choice_path(project_root: Path) -> Path:
def read_initial_choice(project_root: Path) -> dict[str, Any]:
"""현재 초기값(갈래 · 비중) — [확인] 때 적어 둔 것. 없거나 못 읽으면 추종."""
"""현재 초기값(갈래 · 비중 · 제약 겹침) — [확인] 때 적어 둔 것. 없거나 못 읽으면 추종.
`criteria` 는 **사용자가 겹쳐 쓴 값만**(비운 칸은 안 담김) — 나머지는 프로젝트 기본값이라
화면이 다시 열 때 겹친 칸만 채우고 나머지는 placeholder(기본값)로 비워 둔다.
"""
try:
data = json.loads(_choice_path(project_root).read_text(encoding="utf-8"))
logic = data["logic"] if data.get("logic") in MODES else "follow"
return {"logic": logic, "weights": resolve_weights(logic, data.get("weights"))}
criteria = data.get("criteria")
return {
"logic": logic,
"weights": resolve_weights(logic, data.get("weights")),
"criteria": criteria if isinstance(criteria, dict) and criteria else None,
}
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return {"logic": "follow", "weights": resolve_weights("follow", None)}
return {"logic": "follow", "weights": resolve_weights("follow", None), "criteria": None}
def write_initial_choice(
project_root: Path, logic: str, weights: dict[str, float] | None
project_root: Path,
logic: str,
weights: dict[str, float] | None,
criteria: dict[str, float] | None = None,
) -> dict[str, Any]:
"""[확인] 때만 부름 — 작업본 한 칸. 다시 열기 · 다른 PC 에서도 같은 초기값."""
choice = {"logic": logic, "weights": resolve_weights(logic, weights)}
"""[확인] 때만 부름 — 작업본 한 칸. 다시 열기 · 다른 PC 에서도 같은 초기값.
`criteria` 는 사용자가 화면 칸에 직접 넣은 겹침 값만(빈 칸은 담지 않는다) — 프로젝트
기본값이 나중에 바뀌어도 사용자가 안 만진 칸은 그 새 기본값을 그대로 받는다.
"""
choice = {
"logic": logic,
"weights": resolve_weights(logic, weights),
"criteria": criteria or None,
}
path = _choice_path(project_root)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(choice, ensure_ascii=False), encoding="utf-8")
@@ -208,18 +227,27 @@ def generate_initial_route(
weights: dict[str, float] | None,
max_grade: float,
min_radius_m: float,
stream_offset_m: float | None = None,
grade_smooth_m: float | None = None,
) -> dict[str, Any]:
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
공통 제약을 넘지 않는 길을 먼저 찾고(탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 곡선 맞춤 뒤
지반 차까지 없앰), 그런 길이 없을 때만 벌점 탐색으로 가장 적게 넘긴 길을 낸다.
`stream_offset_m`·`grade_smooth_m` 을 안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서
프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정).
"""
if mode not in MODES:
raise ValueError(f"모르는 갈래: {mode}")
if len(expected) < 2:
raise ValueError("예상노선 정점이 2개 미만입니다.")
used = resolve_weights(mode, weights)
grid = _SearchGrid(expected, terrain, streams)
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)
grid = _SearchGrid(
expected, terrain, streams, stream_offset_m=stream_offset, grade_smooth_m=grade_smooth
)
def attempt(hard_cap: float, bound: float | None) -> tuple[Any, dict[str, Any]]:
nodes = _search_route(
@@ -243,9 +271,10 @@ def generate_initial_route(
outline.curves,
max_grade=max_grade,
min_radius_m=min_radius_m,
stream_offset_m=STREAM_OFFSET_M,
stream_offset_m=stream_offset,
road_width_m=ROAD_WIDTH_M,
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
)
return outline, metrics
@@ -280,10 +309,11 @@ def generate_initial_route(
"criteria": {
"max_grade_pct": round(max_grade * 100.0, 2),
"min_radius_m": round(min_radius_m, 2),
"stream_offset_m": STREAM_OFFSET_M,
"stream_offset_m": round(stream_offset, 2),
"road_width_m": ROAD_WIDTH_M,
"corridor_m": CORRIDOR_M,
"grade_smooth_m": GRADE_SMOOTH_M,
"grade_smooth_m": round(grade_smooth, 2),
# 기준값 확정(PLAN 20장, 2026-09-29) — 더는 후보가 아니다. 항상 빈 목록.
"pending": list(PENDING),
},
}
@@ -323,11 +353,15 @@ class _SearchGrid:
expected: list[tuple[float, float]],
terrain: Terrain,
streams: list[list[tuple[float, float]]],
*,
stream_offset_m: float = STREAM_OFFSET_M,
grade_smooth_m: float = GRADE_SMOOTH_M,
) -> None:
step = max(1, int(round(SEARCH_CELL_M / _cell_size(terrain.x))))
self.xs, self.ys = terrain.x[::step], terrain.y[::step]
# 기울기 · 옆경사는 비교값과 같은 고른 지반에서 잰다(`smoothed_ground`)
self.z = smoothed_ground(terrain)[::step, ::step]
self.z = smoothed_ground(terrain, grade_smooth_m)[::step, ::step]
self.stream_offset_m = stream_offset_m
self.cell = _cell_size(self.xs)
self.d_expected = _distance_grid([expected], self.xs, self.ys, self.cell)
self.d_stream = _distance_grid(streams, self.xs, self.ys, self.cell)
@@ -372,8 +406,8 @@ class _SearchGrid:
norm = math.hypot(dr, dc)
grade = np.abs(self.z[second] - self.z[first]) / (self.cell * norm)
# 계류 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만(건너기 · 빠져나가기)
in_buffer = (self.d_stream[first] < STREAM_OFFSET_M) | (
self.d_stream[second] < STREAM_OFFSET_M
in_buffer = (self.d_stream[first] < self.stream_offset_m) | (
self.d_stream[second] < self.stream_offset_m
)
ax = self.away_x[first] + self.away_x[second]
ay = self.away_y[first] + self.away_y[second]
@@ -33,12 +33,14 @@ REASON_STREAM = "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을
REASON_STREAM_TERRAIN = "지형상 불가능 — 기울기 · 반지름을 지키며 계류 버퍼를 비켜 잇는 길 없음"
def smoothed_ground(terrain: Terrain) -> np.ndarray:
"""종단기울기를 잴 지반(`GRADE_SMOOTH_M` 로 고른 DEM) — 탐색과 비교값이 같은 것을 본다."""
def smoothed_ground(terrain: Terrain, grade_smooth_m: float = GRADE_SMOOTH_M) -> np.ndarray:
"""종단기울기를 잴 지반(`grade_smooth_m` 로 고른 DEM) — 탐색과 비교값이 같은 것을 본다.
폭은 화면에서 바꿀 수 있다(PLAN 20장 확정 · 기본값 `GRADE_SMOOTH_M`)."""
from scipy.ndimage import gaussian_filter
spacing = 0.5 * (_spacing(terrain.x) + _spacing(terrain.y))
return gaussian_filter(np.asarray(terrain.z, dtype=np.float64), GRADE_SMOOTH_M / spacing)
return gaussian_filter(np.asarray(terrain.z, dtype=np.float64), grade_smooth_m / spacing)
def station_label(chainage_m: float) -> str:
@@ -107,6 +109,7 @@ def route_metrics(
stream_offset_m: float,
road_width_m: float,
bound_pct: float | None,
grade_smooth_m: float = GRADE_SMOOTH_M,
) -> dict[str, Any]:
"""비교값과 제약 못 지킨 구간 목록. 종단기울기 = 측점(20 m) 사이 고른 지반 기울기.
@@ -115,7 +118,7 @@ def route_metrics(
grid = (terrain.y, terrain.x)
sampler = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=None)
smooth = RegularGridInterpolator(
grid, smoothed_ground(terrain), bounds_error=False, fill_value=None
grid, smoothed_ground(terrain, grade_smooth_m), bounds_error=False, fill_value=None
)
points, chain = _resample(vertices, SAMPLE_M)
length = float(chain[-1])
+25
View File
@@ -94,6 +94,19 @@ class RouteInitialWeights(BaseModel):
grade: float = Field(25.0, ge=0, le=100)
class RouteInitialCriteria(BaseModel):
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
"""
max_grade_pct: float | None = Field(None, ge=0, le=100)
min_radius_m: float | None = Field(None, ge=0)
stream_offset_m: float | None = Field(None, ge=0)
grade_smooth_m: float | None = Field(None, ge=0)
class RouteInitialRequest(BaseModel):
"""초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted."""
@@ -101,6 +114,7 @@ class RouteInitialRequest(BaseModel):
"follow", validation_alias=AliasChoices("logic", "mode")
)
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
class RouteReplanRequest(BaseModel):
@@ -574,6 +588,7 @@ async def replan_route(
project_root,
choice.mode,
choice.weights.model_dump() if choice.weights else None,
(choice.criteria.model_dump(exclude_none=True) or None) if choice.criteria else None,
)
if choice
else await asyncio.to_thread(read_initial_choice, project_root)
@@ -648,6 +663,14 @@ async def initial_route(
grade_class, design_speed, terrain_type = await _road_settings(project_id)
# 비포장 기준 — 포장 예외(18 %)는 포장 여부가 정해진 뒤 종단 설계가 따진다(사용자 확정 대기).
max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0
# 화면 제약 칸(PLAN 20장 확정) — 비운 칸은 위 프로젝트 기본값 그대로, 채운 칸만 겹쳐 쓴다.
criteria = request.criteria
if criteria and criteria.max_grade_pct is not None:
max_grade = criteria.max_grade_pct / 100.0
if criteria and criteria.min_radius_m is not None:
radius_m = criteria.min_radius_m
stream_offset_m = criteria.stream_offset_m if criteria else None
grade_smooth_m = criteria.grade_smooth_m if criteria else None
pool = get_db_pool()
async with pool.acquire() as connection:
selection = await get_surface_confirmation_params(connection, str(project_id))
@@ -666,6 +689,8 @@ async def initial_route(
weights=weights,
max_grade=max_grade,
min_radius_m=radius_m,
stream_offset_m=stream_offset_m,
grade_smooth_m=grade_smooth_m,
)
)
except (FileNotFoundError, ValueError) as error:
+106 -9
View File
@@ -1,19 +1,25 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial.ts
* 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 비중 슬라이더 · [초기화]
* · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값.
* 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 공통 제약 겹침 네 칸 ·
* 비중 슬라이더 · [초기화] · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값.
*
* 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에
* 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고,
* [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시
* 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`).
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
*
* 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정)
* — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고(비운 칸은 서버가 알아서), 그
* 「채운 칸」만 캐시·`initial_choice.json`에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder는
* 그 칸을 처음 비운 채 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import {
fetchRouteInitial,
ROUTE_INITIAL_MISSING,
type RouteInitialCriteria,
type RouteInitialLogic,
type RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
@@ -21,6 +27,7 @@ import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
criteriaLine,
CRITERIA_KEYS,
DEFAULT_WEIGHTS,
metricsLine,
violationsLine,
@@ -33,8 +40,10 @@ import {
serverBase,
WEIGHT_KEYS,
writeInitialChoice,
type CriteriaKey,
type InitialBase,
type InitialChoice,
type InitialCriteria,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
@@ -44,6 +53,37 @@ const WEIGHT_NAME: Record<WeightKey, string> = {
grade: "기울기",
};
/** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */
const CRITERIA_FIELD: Record<
CriteriaKey,
{ label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null }
> = {
max_grade_pct: {
label: "기울기",
unit: "%",
title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)",
of: (c) => c.maxGradePct,
},
min_radius_m: {
label: "최소R",
unit: "m",
title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값",
of: (c) => c.minRadiusM,
},
stream_offset_m: {
label: "계류",
unit: "m",
title: "계류 이격(m) — 비우면 기본값 100m",
of: (c) => c.streamOffsetM,
},
grade_smooth_m: {
label: "지반",
unit: "m",
title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m",
of: (c) => c.gradeSmoothM,
},
};
export interface InitialPanelParams {
overlay: HTMLElement;
projectId: string;
@@ -57,7 +97,11 @@ export interface InitialPanel {
/** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */
ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void;
/** [확인]에 실어 보낼 지금 초기값. */
current: () => { mode: RouteInitialLogic; weights: RouteInitialWeights | null };
current: () => {
mode: RouteInitialLogic;
weights: RouteInitialWeights | null;
criteria: RouteInitialCriteria | null;
};
/** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */
cancel: () => void;
/** [확인] 뒤 결과 캐시를 비웠다 — 초기값은 새 노선의 출발점이라 다시 적는다. */
@@ -80,6 +124,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
<button type="button" class="ui-btn ui-btn--glass" data-act="weights-open" disabled
aria-expanded="false" title="예상 · 토공 · 기울기 비중(합 100)으로 셈합니다">비중 계산</button>
</div>
<div class="b05-routeedit__criteria-row">
${CRITERIA_KEYS.map(
(key) => `<label title="${CRITERIA_FIELD[key].title}">${CRITERIA_FIELD[key].label}
<input type="number" inputmode="decimal" step="0.1" min="0" disabled
data-criteria="${key}" placeholder="기본값" />${CRITERIA_FIELD[key].unit}</label>`,
).join("")}
</div>
<div class="b05-routeedit__weights" hidden>
${WEIGHT_KEYS.map(
(key) => `<label><span>${WEIGHT_NAME[key]}</span>
@@ -107,17 +158,41 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const openWeights = box.querySelector<HTMLButtonElement>('[data-act="weights-open"]')!;
const resetButton = overlay.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
const buttons = [...box.querySelectorAll<HTMLButtonElement>("button"), resetButton];
const criteriaInputs = box.querySelectorAll<HTMLInputElement>("[data-criteria]");
/** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */
let openingChoice = readInitialChoice(projectId);
let choice: InitialChoice | null = openingChoice;
/** 노드 없이 이름만 아는 현재 초기값 — 서버가 적어 둔 것(없으면 예상노선 추종). */
let base: InitialBase = { logic: "follow", weights: null };
let base: InitialBase = { logic: "follow", weights: null, criteria: null };
let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) };
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */
const defaults: Partial<Record<CriteriaKey, number>> = {};
let opening: RouteEditSnapshot | null = null;
let edited = false;
let busy = false;
/** 화면 칸을 값으로 채운다(비운 칸은 빈 문자열 — placeholder 가 기본값을 보인다). */
function setCriteriaInputs(overrides: RouteInitialCriteria | null): void {
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey;
const value = overrides?.[key];
input.value = value === undefined ? "" : String(value);
});
}
/** 화면 칸에 지금 사용자가 넣은 값만(빈 칸은 안 담김) — 다음 요청에 실어 보낼 겹침. */
function currentCriteria(): RouteInitialCriteria {
const out: RouteInitialCriteria = {};
criteriaInputs.forEach((input) => {
const raw = input.value.trim();
if (raw === "") return;
const value = Number(raw);
if (Number.isFinite(value)) out[input.dataset.criteria as CriteriaKey] = value;
});
return out;
}
function render(): void {
const now = choice ?? base;
const logic = now.logic;
@@ -142,30 +217,47 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
}
box.querySelector<HTMLElement>("[data-weight-sum]")!.textContent =
`합 ${weights.expected + weights.earthwork + weights.grade}`;
// 공통 제약 칸 — placeholder 는 그 칸을 처음 비운 채 물었을 때 서버가 준 진짜 기본값.
criteriaInputs.forEach((input) => {
const key = input.dataset.criteria as CriteriaKey;
input.placeholder = defaults[key] !== undefined ? String(defaults[key]) : "기본값";
input.disabled = busy || !opening;
});
buttons.forEach((button) => {
button.disabled = busy || !opening;
});
resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`;
}
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. */
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다.
* `useCriteria` 를 안 주면 화면 칸에 지금 든 값을 그대로 겹침으로 보낸다. */
async function run(
logic: RouteInitialLogic,
allowFallback = false,
useWeights: RouteInitialWeights | null = null,
useCriteria?: RouteInitialCriteria | null,
): Promise<void> {
if (busy || !opening) return;
busy = true;
const sent = logic === "weighted" ? { ...(useWeights ?? weights) } : null;
const criteria = useCriteria !== undefined ? (useCriteria ?? {}) : currentCriteria();
render();
nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`;
try {
const sentCriteria = Object.keys(criteria).length ? criteria : null;
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(sent ? { weights: sent } : {}),
...(sentCriteria ? { criteria: sentCriteria } : {}),
});
if (!box.isConnected) return; // 그 사이 창이 닫혔다.
choice = normalizeRouteInitial(response, logic, sent);
choice = normalizeRouteInitial(response, logic, sent, sentCriteria);
// 비운 칸(안 겹친 칸)을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
for (const key of CRITERIA_KEYS) {
if (criteria[key] !== undefined) continue;
const resolved = choice.criteria ? CRITERIA_FIELD[key].of(choice.criteria) : null;
if (resolved !== null) defaults[key] = resolved;
}
writeInitialChoice(projectId, choice);
params.apply(
copy(choice.snapshot),
@@ -204,7 +296,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
// [초기화] — 마지막에 적용한 초기값으로. 노드가 없으면(서버가 이름만 앎) 그 갈래를 서버에 묻는다.
resetButton.addEventListener("click", () => {
if (!choice) {
void run(base.logic, true, base.weights);
setCriteriaInputs(base.criteria); // 그 사이 손댄 칸이 있으면 서버가 기억한 값으로 되돌린다.
void run(base.logic, true, base.weights, base.criteria);
return;
}
params.apply(
@@ -212,6 +305,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
`초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`,
);
});
setCriteriaInputs(choice?.criteriaOverrides ?? null);
render();
return {
@@ -219,11 +313,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
opening = copy(snapshot);
edited = wasEdited;
// 「현재 초기값」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
// 캐시를 버린다. 캐시는 같은 초기값의 노드를 들고 있을 때만 [초기화]가 쓴다.
// 캐시를 버린다. 캐시는 같은 초기값 · 같은 제약 겹침의 노드를 들고 있을 때만 [초기화]가 쓴다.
if (server) {
base = serverBase(server);
if (choice && !sameBase(choice, base)) choice = null;
if (base.weights) weights = { ...base.weights };
// 화면 칸도 그때 넣었던 값(캐시가 살아 있으면 캐시, 아니면 서버가 기억한 값)으로.
setCriteriaInputs(choice?.criteriaOverrides ?? base.criteria);
openingChoice = choice;
writeInitialChoice(projectId, choice);
}
@@ -231,7 +327,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
},
current: () => {
const now = choice ?? base;
return { mode: now.logic, weights: now.weights };
const criteria = choice ? choice.criteriaOverrides : base.criteria;
return { mode: now.logic, weights: now.weights, criteria };
},
cancel: () => writeInitialChoice(projectId, openingChoice),
keep: () => writeInitialChoice(projectId, choice),
@@ -5,7 +5,11 @@
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { RouteInitialLogic, RouteInitialWeights } from "./B05_Profile_Api_RouteInitial";
import type {
RouteInitialCriteria,
RouteInitialLogic,
RouteInitialWeights,
} from "./B05_Profile_Api_RouteInitial";
import { flattenServerPlan } from "./B05_Profile_UI_RouteEdit_Edits";
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
@@ -23,6 +27,15 @@ export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 2
export const WEIGHT_KEYS = ["expected", "earthwork", "grade"] as const;
export type WeightKey = (typeof WEIGHT_KEYS)[number];
/** 공통 제약 겹침 네 칸 — 서버 몸통 이름 그대로(계획서 20 확정). */
export const CRITERIA_KEYS = [
"max_grade_pct",
"min_radius_m",
"stream_offset_m",
"grade_smooth_m",
] as const;
export type CriteriaKey = (typeof CRITERIA_KEYS)[number];
/** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */
export function rebalanceWeights(
weights: RouteInitialWeights,
@@ -60,14 +73,13 @@ export interface InitialViolation {
reason: string;
}
/** 공통 제약 기준값 — 서버가 준 값. `pending` 이 남아 `confirmed` 가 거짓이면 지식DB 후보. */
/** 공통 제약 기준값 — 서버가 실제로 쓴 값(기본값이든 화면 겹침이든, 계획서 20 확정). */
export interface InitialCriteria {
maxGradePct: number | null;
minRadiusM: number | null;
streamOffsetM: number | null;
/** 기울기를 잴 지반을 고른 폭(m) — 서버 `grade_smooth_m`. */
gradeSmoothM: number | null;
confirmed: boolean;
}
/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */
@@ -77,6 +89,8 @@ export interface InitialChoice {
snapshot: RouteEditSnapshot;
metrics: InitialMetrics | null;
criteria: InitialCriteria | null;
/** 화면 제약 칸에 **사용자가 직접 넣은** 겹침 값만(비운 칸은 안 담김). [확인]·재열기에 쓴다. */
criteriaOverrides: RouteInitialCriteria | null;
/** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */
terrainBoundPct?: number | null;
}
@@ -107,11 +121,13 @@ function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot {
};
}
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). */
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다).
* `criteriaOverrides` 는 이 요청에 실어 보낸 제약 겹침(빈 칸 뺀 것) — 캐시·재열기에 남긴다. */
export function normalizeRouteInitial(
response: unknown,
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteriaOverrides: RouteInitialCriteria | null = null,
): InitialChoice {
// 모양은 계획서 20 「API」 줄 — 노드 · 곡선은 `/route/plan` 과 같은 꼴이다.
const body = (response ?? {}) as Record<string, unknown>;
@@ -153,10 +169,9 @@ export function normalizeRouteInitial(
minRadiusM: pick(c, "min_radius_m"),
streamOffsetM: pick(c, "stream_offset_m"),
gradeSmoothM: pick(c, "grade_smooth_m"),
// 확정 대기 칸 이름 목록이 비어야 확정이다.
confirmed: Array.isArray(c.pending) && c.pending.length === 0,
}
: null,
criteriaOverrides,
};
}
@@ -165,16 +180,36 @@ export interface InitialBase {
logic: RouteInitialLogic;
/** 비중 계산일 때만. 나머지 갈래는 null. */
weights: RouteInitialWeights | null;
/** 사용자가 그때 화면 칸에 넣었던 제약 겹침만(비운 칸은 안 담김). */
criteria: RouteInitialCriteria | null;
}
/** 서버 `current_initial {logic, weights}` 를 편다. 모르는 값이면 예상노선 추종. */
/** 겹침 값 원시 객체를 편다 — 유한수인 칸만 담는다. 빈 결과는 null. */
function parseCriteriaOverrides(raw: unknown): RouteInitialCriteria | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
const out: RouteInitialCriteria = {};
for (const key of CRITERIA_KEYS) {
const value = num(source[key]);
if (value !== null) out[key] = value;
}
return Object.keys(out).length ? out : null;
}
/** 두 겹침 값이 같은가 — 없는 칸은 서로 같다고 본다. */
function criteriaEqual(a: RouteInitialCriteria | null, b: RouteInitialCriteria | null): boolean {
return CRITERIA_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null));
}
/** 서버 `current_initial {logic, weights, criteria}` 를 편다. 모르는 값이면 예상노선 추종. */
export function serverBase(server: unknown): InitialBase {
const body = (server ?? {}) as Record<string, unknown>;
const logic =
typeof body.logic === "string" && body.logic in LOGIC_LABEL
? (body.logic as RouteInitialLogic)
: "follow";
if (logic !== "weighted") return { logic, weights: null };
const criteria = parseCriteriaOverrides(body.criteria);
if (logic !== "weighted") return { logic, weights: null, criteria };
const raw = (body.weights ?? {}) as Record<string, unknown>;
const weights = {
expected: Math.round(Number(raw.expected)),
@@ -182,14 +217,19 @@ export function serverBase(server: unknown): InitialBase {
grade: Math.round(Number(raw.grade)),
};
const valid = WEIGHT_KEYS.every((key) => Number.isFinite(weights[key]));
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS } };
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria };
}
/** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중인가. */
/** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중 · 같은 제약 겹침인가. */
export function sameBase(choice: InitialChoice, base: InitialBase): boolean {
if (choice.logic !== base.logic) return false;
if (base.logic !== "weighted") return true;
return WEIGHT_KEYS.every((key) => choice.weights?.[key] === base.weights?.[key]);
if (
base.logic === "weighted" &&
!WEIGHT_KEYS.every((key) => choice.weights?.[key] === base.weights?.[key])
) {
return false;
}
return criteriaEqual(choice.criteriaOverrides, base.criteria);
}
/** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */
@@ -215,7 +255,7 @@ export function metricsLine(metrics: InitialMetrics | null): string {
].join(" · ");
}
/** 공통 제약 기준값 한 줄 — 서버 값만 보인다. 서버 갈래가 없으면 `/route/plan` 의 최소 R 만. */
/** 공통 제약 기준값 한 줄 — 서버가 실제로 쓴 값(계획서 20 확정 · 화면 칸에서 바꿀 수 있음). */
export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string {
const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null);
const parts = [
@@ -226,8 +266,7 @@ export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: n
// 기울기는 고른 지반으로 잰다 — 그 폭을 서버가 주면 그대로 보인다.
if (criteria?.gradeSmoothM)
parts.push(`기울기 잴 지반 ${fixed(criteria.gradeSmoothM, 0, "m")} 고름`);
const tail = criteria?.confirmed ? "" : " (후보 · 사용자 확정 대기)";
return `공통 제약 ${parts.join(" · ")}${tail}`;
return `공통 제약 ${parts.join(" · ")}`;
}
/** 목록 한 줄에 적을 구간 수 — 넘으면 「외 N곳」(전부는 글상자 풍선에). */
@@ -160,6 +160,35 @@
border-color: var(--color-primary);
}
/* 공통 제약 겹침 네 칸(계획서 20 확정) — 로직 단추 바로 아래. 비우면 placeholder 가 기본값. */
.b05-routeedit__criteria-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-12);
}
.b05-routeedit__criteria-row label {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.b05-routeedit__criteria-row input {
width: 3.6em;
padding: 2px 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
text-align: right;
}
.b05-routeedit__criteria-row input::placeholder {
color: var(--color-text-secondary);
}
.b05-routeedit__weights {
display: grid;
gap: 2px;
@@ -62,69 +62,71 @@ const moves = [
];
// ② 서버 답 펴기 — 노드 목록 꼴과 `/route/plan` 꼴(노드+곡선) 둘 다.
const plain = model.normalizeRouteInitial(
{
nodes: [
{ x: 0, y: 0 },
{ x: 50, y: 10, curve: true, radius_m: 30 },
{ x: 100, y: 0, curve: false },
const plainResponse = {
nodes: [
{ x: 0, y: 0 },
{ x: 50, y: 10, curve: true, radius_m: 30 },
{ x: 100, y: 0, curve: false },
],
// 계획서 20 「API」 줄 모양.
metrics: {
length_m: 1234.56,
max_grade_pct: 12.34,
avg_grade_pct: 6.1,
cut_m3: 1500.4,
fill_m3: 900,
over_limit: { grade: 1, radius: 0, stream: 1, total: 2 },
offset_avg_m: 3.25,
offset_max_m: 9.8,
raw_max_grade_pct: 41.2,
violations: [
{
kind: "grade",
from_m: 180,
to_m: 220,
station: "No.9+0.0",
value: 29.13,
limit: 14,
reason: "지형상 불가능 — 복도 안 어떤 길도 23.5% 이상 기울기를 지나야 이어짐",
},
{
kind: "radius",
from_m: 400,
to_m: 412,
station: "No.20+0.0",
value: 9.5,
limit: 12,
reason: "곡선 둘 자리 모자람 — 앞뒤 직선이 짧아 최소 반지름 원호가 안 들어감",
},
{
kind: "stream",
from_m: 600,
to_m: 700,
station: "No.30+0.0",
value: 32,
limit: 100,
reason: "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을 넘음",
},
],
// 계획서 20 「API」 줄 모양.
metrics: {
length_m: 1234.56,
max_grade_pct: 12.34,
avg_grade_pct: 6.1,
cut_m3: 1500.4,
fill_m3: 900,
over_limit: { grade: 1, radius: 0, stream: 1, total: 2 },
offset_avg_m: 3.25,
offset_max_m: 9.8,
raw_max_grade_pct: 41.2,
violations: [
{
kind: "grade",
from_m: 180,
to_m: 220,
station: "No.9+0.0",
value: 29.13,
limit: 14,
reason: "지형상 불가능 — 복도 안 어떤 길도 23.5% 이상 기울기를 지나야 이어짐",
},
{
kind: "radius",
from_m: 400,
to_m: 412,
station: "No.20+0.0",
value: 9.5,
limit: 12,
reason: "곡선 둘 자리 모자람 — 앞뒤 직선이 짧아 최소 반지름 원호가 안 들어감",
},
{
kind: "stream",
from_m: 600,
to_m: 700,
station: "No.30+0.0",
value: 32,
limit: 100,
reason: "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을 넘음",
},
],
},
criteria: {
max_grade_pct: 14,
min_radius_m: 12,
stream_offset_m: 5,
road_width_m: 4,
corridor_m: 100,
grade_smooth_m: 5,
pending: ["max_grade_pct", "stream_offset_m"],
},
relaxed: true,
terrain_bound_pct: 23.46,
},
"earthwork",
null,
);
criteria: {
max_grade_pct: 14,
min_radius_m: 12,
stream_offset_m: 5,
road_width_m: 4,
corridor_m: 100,
grade_smooth_m: 5,
// 기준값 확정(계획서 20) — 서버가 이제 늘 빈 목록으로 준다.
pending: [],
},
relaxed: true,
terrain_bound_pct: 23.46,
};
const plain = model.normalizeRouteInitial(plainResponse, "earthwork", null);
// 화면 칸에 겹침을 넣어 물었을 때 — `criteriaOverrides` 로 그대로 남는다(빈 칸은 안 담김).
const withOverrides = model.normalizeRouteInitial(plainResponse, "earthwork", null, {
stream_offset_m: 50,
});
const planShape = model.normalizeRouteInitial(
{
nodes: [
@@ -163,7 +165,6 @@ const labels = {
metrics: model.metricsLine(plain.metrics),
metricsNone: model.metricsLine(null),
criteria: model.criteriaLine(plain.criteria, 0),
criteriaConfirmed: model.criteriaLine({ ...plain.criteria, confirmed: true }, 0),
criteriaFallback: model.criteriaLine(null, 15),
violations: model.violationsLine(plain.metrics),
violationsNone: model.violationsLine({ ...plain.metrics, violations: [] }),
@@ -180,32 +181,31 @@ const labels = {
}),
relaxed: model.relaxedLine(plain),
notRelaxed: model.relaxedLine(planShape),
pendingEmpty: model.normalizeRouteInitial(
{
nodes: [
{ x: 0, y: 0 },
{ x: 9, y: 9 },
],
criteria: { pending: [] },
},
"gentle",
null,
).criteria.confirmed,
};
// ⑤ 서버가 적어 둔 현재 초기값(`current_initial`) — 펴기와 캐시 견주기.
// ⑤ 서버가 적어 둔 현재 초기값(`current_initial`) — 펴기와 캐시 견주기(제약 겹침 포함).
const bases = {
follow: model.serverBase({ logic: "follow", weights: { expected: 100, earthwork: 0, grade: 0 } }),
follow: model.serverBase({
logic: "follow",
weights: { expected: 100, earthwork: 0, grade: 0 },
criteria: null,
}),
weighted: model.serverBase({
logic: "weighted",
weights: { expected: 70.0, earthwork: 15.0, grade: 15.0 },
criteria: { stream_offset_m: 50.0 },
}),
unknown: model.serverBase({ logic: "nope" }),
none: model.serverBase(null),
};
const sameAsServer = {
earthVsFollow: model.sameBase(plain, bases.follow),
earthVsEarth: model.sameBase(plain, model.serverBase({ logic: "earthwork" })),
earthVsEarth: model.sameBase(plain, model.serverBase({ logic: "earthwork", criteria: null })),
// 같은 갈래라도 그때 넣었던 제약 겹침이 다르면(캐시엔 없음, 서버는 50m) 다른 것으로 본다.
earthVsEarthDiffCriteria: model.sameBase(
withOverrides,
model.serverBase({ logic: "earthwork", criteria: null }),
),
weightedSame: model.sameBase(
planShape,
model.serverBase({ logic: "weighted", weights: { expected: 60, earthwork: 20, grade: 20 } }),
@@ -217,6 +217,8 @@ const sameAsServer = {
model.writeInitialChoice("p-1", plain);
const readBack = model.readInitialChoice("p-1");
const other = model.readInitialChoice("p-2");
model.writeInitialChoice("p-3", withOverrides);
const readBackOverrides = model.readInitialChoice("p-3");
const keys = [...session._map.keys()];
session._map.set(
keys[0],
@@ -242,6 +244,7 @@ process.stdout.write(
tooShort,
labels,
readBack,
readBackOverrides,
other,
keys,
badLogic,
@@ -1,8 +1,9 @@
/* 초기노선 갈래 **저장 왕복**(계획서 20) 헬퍼 — 서버 답을 화면 코드 그대로 편다.
*
* 표준입력 JSON `{ jobs: [...] }` 를 받아 일마다 결과 한 줄을 돌려준다.
* - `apply` : `/route/initial` 답 → 편집값(`normalizeRouteInitial`) → [확인] 몸통
* (`_RouteEdit_Apply` 의 vertices 만들기 · `_Initial.current()` 의 initial)
* - `apply` : `/route/initial` 답(+보낸 `criteria` 겹침) → 편집값(`normalizeRouteInitial`)
* → [확인] 몸통(`_RouteEdit_Apply` 의 vertices 만들기 · `_Initial.current()` 의
* initial{mode, weights, criteria})
* - `reopen` : `/route/plan` 답 → 다시 연 노드(`flattenServerPlan`) · 현재 초기값(`serverBase`)
* · 캐시 초기값과 같은가(`sameBase` — [초기화]가 캐시 노드를 쓸지 서버에 물을지)
*
@@ -56,7 +57,12 @@ const edits = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ed
const { jobs } = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.kind === "apply") {
const choice = model.normalizeRouteInitial(job.response, job.logic, job.weights ?? null);
const choice = model.normalizeRouteInitial(
job.response,
job.logic,
job.weights ?? null,
job.criteria ?? null,
);
const { planned, curveOn, curveRadius } = choice.snapshot;
return {
choice,
@@ -68,7 +74,11 @@ const out = jobs.map((job) => {
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
initial: { mode: choice.logic, weights: choice.weights },
initial: {
mode: choice.logic,
weights: choice.weights,
criteria: choice.criteriaOverrides,
},
},
label: model.choiceLabel(choice.logic, choice.weights),
metrics: model.metricsLine(choice.metrics),
+22 -12
View File
@@ -2,8 +2,10 @@
"""계획노선 편집 창 — **초기노선 갈래**(계획서 20) 셈 회귀검증.
비중 슬라이더 셋은 합 100 을 지키고(하나를 옮기면 나머지가 비율대로), 서버 답은 노드 목록 꼴과
`/route/plan` 꼴(노드+곡선) 둘 다 편집값 한 벌로 펴야 한다. 비교값 · 기준값은 서버 값을 글로만
보이고, 확정 표시가 없으면 「사용자 확정 대기」를 붙인다. 마지막 초기값은 결과 통 프로젝트 칸.
`/route/plan` 꼴(노드+곡선) 둘 다 편집값 한 벌로 펴야 한다. 기준값 확정(2026-09-29) 뒤로는
비교값 · 기준값 글에 「후보 · 사용자 확정 대기」 꼬리표가 안 붙는다. 화면 제약 칸에 사용자가
겹쳐 넣은 값(`criteriaOverrides`)만 캐시·서버 `current_initial` 과 오가고, 그 겹침이 서버 것과
다르면 캐시를 버린다(`sameBase`). 마지막 초기값은 결과 통 프로젝트 칸.
Node 헬퍼(`helper_b05_routeedit_initial.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
"""
@@ -80,15 +82,13 @@ def test_비교값과_기준값_글(results):
" · 기준 초과 2구간 · 이격 3.3m"
)
assert "로직을 누르면" in labels["metricsNone"]
# 기준값 확정(2026-09-29) — 「후보 · 사용자 확정 대기」 꼬리표가 더는 안 붙는다.
assert labels["criteria"] == (
"공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m · 기울기 잴 지반 5m 고름"
" (후보 · 사용자 확정 대기)"
)
assert labels["criteriaConfirmed"].endswith("지반 5m 고름")
assert labels["pendingEmpty"] is True
assert labels["criteriaFallback"] == (
"공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ — (후보 · 사용자 확정 대기)"
)
assert "확정 대기" not in labels["criteria"]
assert labels["criteriaFallback"] == "공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ —"
assert "확정 대기" not in labels["criteriaFallback"]
def test_제약_못_지킨_구간과_상한_풂(results):
@@ -109,26 +109,36 @@ def test_제약_못_지킨_구간과_상한_풂(results):
def test_서버가_적어_둔_현재_초기값(results):
bases = results["bases"]
assert bases["follow"] == {"logic": "follow", "weights": None}
assert bases["follow"] == {"logic": "follow", "weights": None, "criteria": None}
assert bases["weighted"] == {
"logic": "weighted",
"weights": {"expected": 70, "earthwork": 15, "grade": 15},
"criteria": {"stream_offset_m": 50},
}
assert bases["unknown"] == {"logic": "follow", "weights": None}
assert bases["none"] == {"logic": "follow", "weights": None}
assert bases["unknown"] == {"logic": "follow", "weights": None, "criteria": None}
assert bases["none"] == {"logic": "follow", "weights": None, "criteria": None}
same = results["sameAsServer"]
assert same == {
"earthVsFollow": False,
"earthVsEarth": True,
# 같은 갈래라도 넣은 제약 겹침(계류 이격 50m)이 서버 기억(안 겹침)과 다르면 캐시를 버린다.
"earthVsEarthDiffCriteria": False,
"weightedSame": True,
"weightedOther": False,
}
def test_캐시는_결과_통_프로젝트_칸(results):
assert results["keys"] == ["aislo:result:routeedit-initial:p-1"]
assert results["keys"] == [
"aislo:result:routeedit-initial:p-1",
"aislo:result:routeedit-initial:p-3",
]
assert results["readBack"]["logic"] == "earthwork"
assert results["readBack"]["snapshot"]["planned"] == [[0, 0], [50, 10], [100, 0]]
# 화면 칸에 아무것도 안 겹쳤으면 `criteriaOverrides` 는 null.
assert results["readBack"]["criteriaOverrides"] is None
# 계류 이격만 겹쳐 물었으면 그 한 칸만 남는다(나머지는 기본값이라 안 담김).
assert results["readBackOverrides"]["criteriaOverrides"] == {"stream_offset_m": 50}
assert results["other"] is None
assert results["badLogic"] is None
assert results["cleared"] is None
@@ -8,6 +8,9 @@
④ [초기화] — 캐시가 있으면 그 노드, 없으면(다른 PC) 서버가 같은 갈래로 같은 노드를 냄
화면 쪽 셈은 `helper_b05_routeedit_initial_apply.cjs` 가 화면 코드 그대로 돌린다.
화면 제약 칸(종단기울기 · 최소 반지름 · 계류 이격 · 지반 고름, 계획서 20 확정)에 겹친 값도
같은 차례로 왕복 — 겹친 칸만 `current_initial.criteria` 에 남고 안 겹친 칸은 프로젝트 기본값.
- 파일: 프로젝트 폴더를 임시 폴더로 복사(원본 입력 · 점군 · 큰 파일 제외) ·
`STORAGE_BASE_DIR` 를 그리로.
- DB: 읽기 전용 세션으로 진짜 DB 를 읽기만 함.
@@ -134,10 +137,17 @@ def _screen(jobs: list[dict[str, Any]]) -> list[Any]:
return json.loads(out.stdout)
def _initial(api, logic: str, weights: dict[str, float] | None) -> dict[str, Any]:
def _initial(
api,
logic: str,
weights: dict[str, float] | None,
criteria: dict[str, float] | None = None,
) -> dict[str, Any]:
body: dict[str, Any] = {"logic": logic}
if weights:
body["weights"] = weights
if criteria:
body["criteria"] = criteria
response = api.post(f"/api/projects/{PROJECT_ID}/route/initial", json=body)
assert response.status_code == 200, response.text
return response.json()
@@ -255,3 +265,31 @@ def test_초기화는_마지막_초기값으로(rounds, api):
# 다른 갈래들과는 다른 노선이다(되돌림이 실제로 갈래를 가림).
others = [r["applied"]["choice"]["snapshot"]["planned"] for r in rounds["rounds"][:-1]]
assert all(not _close(o, cached) for o in others)
def test_제약_겹침도_확인_뒤_그대로(api, rounds):
"""화면 제약 칸(계류 이격 30m)만 겹쳐 물으면 답에 그 값이 실리고, [확인] → 다시 열기에도
그 한 칸만 남는다(나머지는 기본값이라 안 담김 — 계획서 20 확정값이 프로젝트 기본값)."""
criteria = {"stream_offset_m": 30.0}
answer = _initial(api, "earthwork", None, criteria)
assert answer["criteria"]["stream_offset_m"] == 30.0
[applied] = _screen(
[
{
"kind": "apply",
"response": answer,
"logic": "earthwork",
"weights": None,
"criteria": criteria,
}
]
)
assert applied["body"]["initial"]["criteria"] == criteria
confirmed = api.post(f"/api/projects/{PROJECT_ID}/route/replan", json=applied["body"])
assert confirmed.status_code == 200, confirmed.text
assert confirmed.json()["current_initial"]["criteria"] == criteria
plan = api.get(f"/api/projects/{PROJECT_ID}/route/plan").json()
assert plan["current_initial"]["criteria"] == criteria
[reopened] = _screen([{"kind": "reopen", "plan": plan, "cached": applied["choice"]}])
assert reopened["base"]["criteria"] == criteria
assert reopened["sameAsCache"] is True # 캐시와 서버 겹침이 같으니 [초기화]가 캐시를 쓴다
+68 -4
View File
@@ -150,20 +150,65 @@ def test_unknown_mode_rejected(hill: Terrain) -> None:
_run(hill, "shortest")
def test_criteria_pending(hill: Terrain) -> None:
assert _run(hill, "follow")["criteria"]["pending"] == list(PENDING)
def test_criteria_confirmed(hill: Terrain) -> None:
"""기준값 확정(2026-09-29 사용자 결정) — `pending` 은 이제 늘 빈 목록."""
assert _run(hill, "follow")["criteria"]["pending"] == [] == list(PENDING)
def test_criteria_overrides_change_result(hill: Terrain) -> None:
"""화면 제약 칸(계류 이격 · 지반 고름)을 겹치면 그 값으로 셈하고, 안 겹치면 기본값."""
default = generate_initial_route(
_line(200.0),
hill,
[],
mode="follow",
weights=None,
max_grade=GRADE,
min_radius_m=RADIUS,
)
assert default["criteria"]["stream_offset_m"] == STREAM_OFFSET_M
overridden = generate_initial_route(
_line(200.0),
hill,
[],
mode="follow",
weights=None,
max_grade=GRADE,
min_radius_m=RADIUS,
stream_offset_m=30.0,
grade_smooth_m=2.0,
)
assert overridden["criteria"]["stream_offset_m"] == 30.0
assert overridden["criteria"]["grade_smooth_m"] == 2.0
assert overridden["criteria"]["max_grade_pct"] == default["criteria"]["max_grade_pct"]
_kept(overridden)
def test_initial_choice_roundtrip(tmp_path) -> None:
"""[확인] 때 적은 현재 초기값을 다시 읽음 · 없거나 깨지면 추종."""
assert read_initial_choice(tmp_path)["logic"] == "follow"
"""[확인] 때 적은 현재 초기값(갈래 · 비중 · 제약 겹침)을 다시 읽음 · 없거나 깨지면 추종."""
assert read_initial_choice(tmp_path) == {
"logic": "follow",
"weights": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
"criteria": None,
}
write_initial_choice(tmp_path, "weighted", {"expected": 60, "earthwork": 20, "grade": 20})
assert read_initial_choice(tmp_path) == {
"logic": "weighted",
"weights": {"expected": 60.0, "earthwork": 20.0, "grade": 20.0},
"criteria": None,
}
# 사용자가 화면 칸에 겹쳐 넣은 값만(빈 칸은 안 담김) — 남으면 다시 열 때 그 칸만 채운다.
write_initial_choice(
tmp_path, "earthwork", None, {"stream_offset_m": 30.0, "grade_smooth_m": 2.0}
)
assert read_initial_choice(tmp_path) == {
"logic": "earthwork",
"weights": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0},
"criteria": {"stream_offset_m": 30.0, "grade_smooth_m": 2.0},
}
(tmp_path / "B05_Profile" / "route" / "initial_choice.json").write_text("{", encoding="utf-8")
assert read_initial_choice(tmp_path)["logic"] == "follow"
assert read_initial_choice(tmp_path)["criteria"] is None
def test_request_accepts_logic_name() -> None:
@@ -173,3 +218,22 @@ def test_request_accepts_logic_name() -> None:
assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork"
replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}})
assert replan.initial is not None and replan.initial.mode == "weighted"
def test_request_accepts_criteria_overrides() -> None:
"""화면 제약 칸 — 비운 칸은 None(서버 기본값), 채운 칸만 값이 실린다(계획서 20)."""
from B05_Profile.B05_Profile_Router_Replan import RouteInitialRequest, RouteReplanRequest
request = RouteInitialRequest.model_validate(
{"mode": "earthwork", "criteria": {"stream_offset_m": 30, "grade_smooth_m": 2}}
)
assert request.criteria is not None
assert request.criteria.stream_offset_m == 30
assert request.criteria.grade_smooth_m == 2
assert request.criteria.max_grade_pct is None
assert request.criteria.min_radius_m is None
replan = RouteReplanRequest.model_validate(
{"vertices": [], "initial": {"mode": "weighted", "criteria": {"max_grade_pct": 12}}}
)
assert replan.initial is not None and replan.initial.criteria is not None
assert replan.initial.criteria.max_grade_pct == 12