diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts
index 96605545..7affdc8a 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts
@@ -23,9 +23,12 @@ import {
criteriaLine,
DEFAULT_WEIGHTS,
metricsLine,
+ violationsLine,
+ violationText,
normalizeRouteInitial,
readInitialChoice,
rebalanceWeights,
+ relaxedLine,
sameBase,
serverBase,
WEIGHT_KEYS,
@@ -90,12 +93,16 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
+
+
`;
overlay.querySelector(".b05-routeedit__canvas-wrap")!.append(box);
const nowLine = box.querySelector(".b05-routeedit__initial-now")!;
const metricsEl = box.querySelector('[data-line="metrics"]')!;
const criteriaEl = box.querySelector('[data-line="criteria"]')!;
+ const violationsEl = box.querySelector('[data-line="violations"]')!;
+ const relaxedEl = box.querySelector('[data-line="relaxed"]')!;
const weightsBox = box.querySelector(".b05-routeedit__weights")!;
const openWeights = box.querySelector('[data-act="weights-open"]')!;
const resetButton = overlay.querySelector('[data-act="reset"]')!;
@@ -120,6 +127,14 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
? "비교값 — 서버 답에 없습니다."
: metricsLine(choice?.metrics ?? null);
criteriaEl.textContent = criteriaLine(choice?.criteria ?? null, params.planMinRadiusM());
+ // 제약 못 지킨 구간 · 상한 풂 — 서버가 준 목록 그대로(계획서 20). 없는 칸이면 숨긴다.
+ violationsEl.textContent = violationsLine(choice?.metrics ?? null);
+ violationsEl.title = (choice?.metrics?.violations ?? [])
+ .map((item) => `${violationText(item)} — ${item.reason}`)
+ .join("\n");
+ violationsEl.hidden = !violationsEl.textContent;
+ relaxedEl.textContent = relaxedLine(choice);
+ relaxedEl.hidden = !relaxedEl.textContent;
box.dataset.logic = logic;
for (const key of WEIGHT_KEYS) {
box.querySelector(`[data-weight="${key}"]`)!.value = String(weights[key]);
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts
index 0a8635e0..8ab9f585 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts
@@ -47,6 +47,17 @@ export interface InitialMetrics {
fillM3: number | null;
overLimitCount: number | null;
offsetM: number | null;
+ /** 제약 못 지킨 구간 — 서버 `metrics.violations`. 칸이 없으면 null(옛 답). */
+ violations: InitialViolation[] | null;
+}
+
+/** 제약 못 지킨 구간 하나 — 종단기울기 · 곡선반지름 · 계류 이격. */
+export interface InitialViolation {
+ kind: string;
+ station: string;
+ value: number | null;
+ limit: number | null;
+ reason: string;
}
/** 공통 제약 기준값 — 서버가 준 값. `pending` 이 남아 `confirmed` 가 거짓이면 지식DB 후보. */
@@ -54,6 +65,8 @@ export interface InitialCriteria {
maxGradePct: number | null;
minRadiusM: number | null;
streamOffsetM: number | null;
+ /** 기울기를 잴 지반을 고른 폭(m) — 서버 `grade_smooth_m`. */
+ gradeSmoothM: number | null;
confirmed: boolean;
}
@@ -64,6 +77,8 @@ export interface InitialChoice {
snapshot: RouteEditSnapshot;
metrics: InitialMetrics | null;
criteria: InitialCriteria | null;
+ /** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */
+ terrainBoundPct?: number | null;
}
const num = (value: unknown): number | null =>
@@ -120,13 +135,24 @@ export function normalizeRouteInitial(
fillM3: pick(m, "fill_m3"),
overLimitCount: pick(over, "total"),
offsetM: pick(m, "offset_avg_m"),
+ violations: Array.isArray(m.violations)
+ ? (m.violations as Array>).map((item) => ({
+ kind: String(item.kind ?? ""),
+ station: String(item.station ?? ""),
+ value: num(item.value),
+ limit: num(item.limit),
+ reason: String(item.reason ?? ""),
+ }))
+ : null,
}
: null,
+ terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null,
criteria: body.criteria
? {
maxGradePct: pick(c, "max_grade_pct"),
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,
}
@@ -197,10 +223,48 @@ export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: n
`최소 R ${fixed(radius, 0, "m")}`,
`계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`,
];
+ // 기울기는 고른 지반으로 잰다 — 그 폭을 서버가 주면 그대로 보인다.
+ if (criteria?.gradeSmoothM)
+ parts.push(`기울기 잴 지반 ${fixed(criteria.gradeSmoothM, 0, "m")} 고름`);
const tail = criteria?.confirmed ? "" : " (후보 · 사용자 확정 대기)";
return `공통 제약 ${parts.join(" · ")}${tail}`;
}
+/** 목록 한 줄에 적을 구간 수 — 넘으면 「외 N곳」(전부는 글상자 풍선에). */
+const VIOLATION_SHOWN = 5;
+
+/** 구간 하나 — 「No.9 29.1%(지형상 불가능)」. 사유는 「 — 」 앞 머리만. */
+export function violationText(item: InitialViolation): string {
+ const value =
+ item.kind === "grade"
+ ? fixed(item.value, 1, "%")
+ : item.kind === "radius"
+ ? `R ${fixed(item.value, 1, "m")}`
+ : item.kind === "stream"
+ ? `계류 ${fixed(item.value, 1, "m")}`
+ : fixed(item.value, 1, "");
+ const head = item.reason.split(" — ")[0].trim();
+ return `${item.station} ${value}${head ? `(${head})` : ""}`;
+}
+
+/** 제약 못 지킨 구간 한 줄 — 없으면 「모두 지킴」. 서버가 목록을 안 줬으면 빈 글. */
+export function violationsLine(metrics: InitialMetrics | null): string {
+ const items = metrics?.violations;
+ if (!items) return "";
+ if (!items.length) return "제약 못 지킨 구간 없음 — 모두 지킴";
+ const shown = items.slice(0, VIOLATION_SHOWN).map(violationText).join(" · ");
+ const more = items.length > VIOLATION_SHOWN ? ` 외 ${items.length - VIOLATION_SHOWN}곳` : "";
+ return `제약 못 지킨 구간 ${items.length}곳: ${shown}${more}`;
+}
+
+/** 상한을 풀었을 때만 — 「지형상 14% 로 못 이어져 상한을 23.5% 로 풂」. 아니면 빈 글. */
+export function relaxedLine(choice: InitialChoice | null): string {
+ const bound = choice?.terrainBoundPct;
+ if (bound === null || bound === undefined) return "";
+ const limit = fixed(choice?.criteria?.maxGradePct ?? null, 1, "%");
+ return `지형상 ${limit} 로 못 이어져 상한을 ${fixed(bound, 1, "%")} 로 풂`;
+}
+
/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 null(= 예상노선 추종). */
export function readInitialChoice(projectId: string): InitialChoice | null {
const saved = readState("routeedit-initial", projectId);
diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
index 33b79f1c..4b17d745 100644
--- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
+++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
@@ -197,6 +197,12 @@
line-height: 1.45;
}
+/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */
+.b05-routeedit__initial-line.is-warn {
+ color: var(--color-warning);
+ font-weight: 600;
+}
+
.b05-routeedit__legend {
display: inline-flex;
align-items: center;
diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs
index d45ec375..2adca828 100644
--- a/resources/tester/helper_b05_routeedit_initial.cjs
+++ b/resources/tester/helper_b05_routeedit_initial.cjs
@@ -79,6 +79,36 @@ const plain = model.normalizeRouteInitial(
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,
@@ -86,8 +116,11 @@ const plain = model.normalizeRouteInitial(
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,
@@ -132,6 +165,21 @@ const labels = {
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: [] }),
+ violationsOld: model.violationsLine({ ...plain.metrics, violations: null }),
+ violationsMany: model.violationsLine({
+ ...plain.metrics,
+ violations: Array.from({ length: 7 }, (_, i) => ({
+ kind: "grade",
+ station: `No.${i}+0.0`,
+ value: 15 + i,
+ limit: 14,
+ reason: "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음",
+ })),
+ }),
+ relaxed: model.relaxedLine(plain),
+ notRelaxed: model.relaxedLine(planShape),
pendingEmpty: model.normalizeRouteInitial(
{
nodes: [
diff --git a/resources/tester/helper_b05_routeedit_initial_apply.cjs b/resources/tester/helper_b05_routeedit_initial_apply.cjs
index b267032d..dfc77ef8 100644
--- a/resources/tester/helper_b05_routeedit_initial_apply.cjs
+++ b/resources/tester/helper_b05_routeedit_initial_apply.cjs
@@ -72,6 +72,9 @@ const out = jobs.map((job) => {
},
label: model.choiceLabel(choice.logic, choice.weights),
metrics: model.metricsLine(choice.metrics),
+ violations: model.violationsLine(choice.metrics),
+ relaxed: model.relaxedLine(choice),
+ criteria: model.criteriaLine(choice.criteria, 0),
};
}
const flat = edits.flattenServerPlan(job.plan.nodes ?? [], job.plan.curves ?? []);
diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py
index 5b44155d..59160ce2 100644
--- a/resources/tester/test_b05_routeedit_initial.py
+++ b/resources/tester/test_b05_routeedit_initial.py
@@ -81,15 +81,32 @@ def test_비교값과_기준값_글(results):
)
assert "로직을 누르면" in labels["metricsNone"]
assert labels["criteria"] == (
- "공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m (후보 · 사용자 확정 대기)"
+ "공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m · 기울기 잴 지반 5m 고름"
+ " (후보 · 사용자 확정 대기)"
)
- assert labels["criteriaConfirmed"].endswith("5.0m")
+ assert labels["criteriaConfirmed"].endswith("지반 5m 고름")
assert labels["pendingEmpty"] is True
assert labels["criteriaFallback"] == (
"공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ — (후보 · 사용자 확정 대기)"
)
+def test_제약_못_지킨_구간과_상한_풂(results):
+ labels = results["labels"]
+ assert labels["violations"] == (
+ "제약 못 지킨 구간 3곳: No.9+0.0 29.1%(지형상 불가능)"
+ " · No.20+0.0 R 9.5m(곡선 둘 자리 모자람) · No.30+0.0 계류 32.0m(계류 버퍼 안)"
+ )
+ assert labels["violationsNone"] == "제약 못 지킨 구간 없음 — 모두 지킴"
+ assert labels["violationsOld"] == "" # 옛 답(목록 칸 없음)은 줄을 숨긴다
+ assert labels["violationsMany"].startswith(
+ "제약 못 지킨 구간 7곳: No.0+0.0 15.0%(곡선 맞춤 뒤 지반 차)"
+ )
+ assert labels["violationsMany"].endswith(" 외 2곳")
+ assert labels["relaxed"] == "지형상 14.0% 로 못 이어져 상한을 23.5% 로 풂"
+ assert labels["notRelaxed"] == ""
+
+
def test_서버가_적어_둔_현재_초기값(results):
bases = results["bases"]
assert bases["follow"] == {"logic": "follow", "weights": None}
diff --git a/resources/tester/test_b05_routeedit_initial_roundtrip.py b/resources/tester/test_b05_routeedit_initial_roundtrip.py
index 195423b6..00494ff1 100644
--- a/resources/tester/test_b05_routeedit_initial_roundtrip.py
+++ b/resources/tester/test_b05_routeedit_initial_roundtrip.py
@@ -192,6 +192,28 @@ def test_비교값이_응답에_다_있음(rounds):
assert "—" not in item["applied"]["metrics"], item["applied"]["metrics"]
+def test_제약_못_지킨_구간이_목록과_화면_줄에(rounds):
+ for item in rounds["rounds"]:
+ answer, applied = item["answer"], item["applied"]
+ violations = answer["metrics"]["violations"]
+ assert answer["metrics"]["over_limit"]["total"] == len(violations)
+ for entry in violations:
+ assert entry["kind"] in ("grade", "radius", "stream")
+ assert entry["station"].startswith("No.") and entry["reason"]
+ assert isinstance(entry["value"], (int, float))
+ head = (
+ f"제약 못 지킨 구간 {len(violations)}곳: " if violations else "제약 못 지킨 구간 없음"
+ )
+ assert applied["violations"].startswith(head), applied["violations"]
+ if answer.get("relaxed"):
+ bound = f"{answer['terrain_bound_pct']:.1f}%"
+ assert applied["relaxed"].endswith(f"상한을 {bound} 로 풂"), applied["relaxed"]
+ else:
+ assert applied["relaxed"] == ""
+ smooth = answer["criteria"]["grade_smooth_m"]
+ assert f"기울기 잴 지반 {smooth:.0f}m 고름" in applied["criteria"]
+
+
def test_확인하면_서버가_초기값을_적고_다시_열면_그대로(rounds, api):
assert len(api.recomputed) == len(LOGICS)
for item in rounds["rounds"]: