diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts
index 96dabba3e..08f3e7d91 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts
@@ -1,4 +1,5 @@
import { createButton } from "@ui/ui_template_elements";
+import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
/* =============================================================================
* B05_Profile_UI_RouteEdit_Chrome.ts
@@ -72,6 +73,8 @@ export function createRouteEditChrome(
+
@@ -152,6 +155,7 @@ export function createRouteEditChrome(
)!;
const terrainSelect = overlay.querySelector('[data-act="terrain"]')!;
const stationSnapButton = overlay.querySelector('[data-act="station-snap"]')!;
+ bindFootprintToggle(overlay.querySelector('[data-act="footprint"]')!);
return {
overlay,
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Footprint.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Footprint.ts
new file mode 100644
index 000000000..de4b89298
--- /dev/null
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Footprint.ts
@@ -0,0 +1,93 @@
+/* =============================================================================
+ * B05_Profile_UI_RouteEdit_Footprint.ts
+ * 노선 편집 평면에 **평면 발자국**(도로 끝 · 비탈 끝)을 그린다(PLAN 346-2).
+ *
+ * 값 = 가게 키 `footprint`(계산 묶음 footprint 단계가 씀) · 선 갈래 = `logic/_Footprint_Strokes`.
+ * 가게 값이 바뀌거나 [발자국] 단추를 누르면 마지막 장면을 한 번 다시 그린다(프레임마다 한 번) —
+ * 편집기 본체가 그릴 때마다 `drawFootprint` 가 다시 그릴 길을 새로 받아 둔다.
+ * 켬끔은 이 페이지 동안만(기본 켬 · 계정 설정 아님).
+ * ========================================================================== */
+
+import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
+import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
+import {
+ type FootprintStrokeKind,
+ type FootprintValue,
+ footprintStrokes,
+} from "./logic/B05_Profile_Footprint_Strokes";
+import "./B05_Profile_UI_Style_RouteEdit_Footprint.css";
+
+/** 갈래 → 색 토큰 · 대체 색. */
+const STROKE_STYLE: Record = {
+ road: ["--b05-footprint-road", "#475569"],
+ cut: ["--b05-footprint-cut", "#dc2626"],
+ fill: ["--b05-footprint-fill", "#2563eb"],
+ slope: ["--b05-footprint-road", "#475569"],
+};
+const STROKE_WIDTH = 1.4;
+
+let shown = true;
+let redraw: (() => void) | null = null;
+let frame = 0;
+let listening: { projectId: string; stop: () => void } | null = null;
+
+function scheduleRedraw(): void {
+ if (frame) return;
+ frame = requestAnimationFrame(() => {
+ frame = 0;
+ redraw?.();
+ });
+}
+
+function listen(projectId: string): void {
+ if (listening?.projectId === projectId) return;
+ listening?.stop();
+ const stop = useDesignStore(projectId, null).subscribe("footprint", scheduleRedraw);
+ listening = { projectId, stop };
+}
+
+/** [발자국] 단추 — 누르면 켬끔 · 다시 그림. */
+export function bindFootprintToggle(button: HTMLButtonElement): void {
+ const paint = (): void => {
+ button.classList.toggle("is-active", shown);
+ button.setAttribute("aria-pressed", String(shown));
+ };
+ paint();
+ button.addEventListener("click", () => {
+ shown = !shown;
+ paint();
+ scheduleRedraw();
+ });
+}
+
+/** 발자국을 그린다(회전 · 등고선 위 · 노선 아래 자리에서 부름). `again` = 장면을 다시 그리는 길. */
+export function drawFootprint(
+ context: CanvasRenderingContext2D,
+ toScreen: (vertex: Vertex) => [number, number],
+ style: CSSStyleDeclaration,
+ again: () => void,
+): void {
+ redraw = again;
+ const projectId = draftProjectId();
+ listen(projectId);
+ if (!shown) return;
+ const strokes = footprintStrokes(
+ useDesignStore(projectId, null).get("footprint") as FootprintValue | null,
+ );
+ if (!strokes.length) return;
+ context.save();
+ context.setLineDash([]);
+ context.lineWidth = STROKE_WIDTH;
+ for (const { kind, points } of strokes) {
+ const [token, fallback] = STROKE_STYLE[kind];
+ context.strokeStyle = style.getPropertyValue(token).trim() || fallback;
+ context.beginPath();
+ points.forEach((vertex, index) => {
+ const [x, y] = toScreen(vertex);
+ if (index === 0) context.moveTo(x, y);
+ else context.lineTo(x, y);
+ });
+ context.stroke();
+ }
+ context.restore();
+}
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
index 90c6664ec..e3c70d476 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
@@ -31,6 +31,7 @@ import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame";
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock";
+import { drawFootprint } from "./B05_Profile_UI_RouteEdit_Footprint";
/** 노드 반지름(px). */
const NODE_R = 4;
@@ -202,6 +203,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
}
context.restore();
+ // 평면 발자국(도로 끝 · 비탈 끝 · 346-2) — 등고선 위 · 노선 아래.
+ drawFootprint(context, toScreen, style, () => drawRouteEditScene(context, scene));
strokePolyline(
context,
toScreen,
diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit_Footprint.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit_Footprint.css
new file mode 100644
index 000000000..f2e5fa3c0
--- /dev/null
+++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit_Footprint.css
@@ -0,0 +1,20 @@
+/* 노선 편집 평면 발자국(PLAN 346-2) — 도로 끝 · 비탈 끝 깎기(붉음) · 쌓기(파랑) */
+:root {
+ --b05-footprint-road: #475569;
+ --b05-footprint-cut: #dc2626;
+ --b05-footprint-fill: #2563eb;
+}
+
+:root[data-theme="dark"] {
+ --b05-footprint-road: #cbd5e1;
+ --b05-footprint-cut: #f87171;
+ --b05-footprint-fill: #60a5fa;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --b05-footprint-road: #cbd5e1;
+ --b05-footprint-cut: #f87171;
+ --b05-footprint-fill: #60a5fa;
+ }
+}
diff --git a/B05_Profile/logic/B05_Profile_Footprint_Strokes.ts b/B05_Profile/logic/B05_Profile_Footprint_Strokes.ts
new file mode 100644
index 000000000..da638f3c9
--- /dev/null
+++ b/B05_Profile/logic/B05_Profile_Footprint_Strokes.ts
@@ -0,0 +1,54 @@
+/* =============================================================================
+ * B05_Profile_Footprint_Strokes.ts
+ * 평면 발자국(가게 키 `footprint` · 계약 `PlanFootprint`) → 그릴 선 갈래(PLAN 346-2).
+ *
+ * 도로 끝 좌 · 우 = `road` · 비탈 끝 = 점마다 깎기 여부(`slopeLeftCut` · `slopeRightCut` —
+ * footprint 단계가 붙임)로 `cut` · `fill` 조각 · 깎기 여부가 없으면 `slope` 한 줄.
+ * 조각이 바뀌는 점은 앞뒤 조각이 함께 가짐(선이 끊겨 보이지 않게 · `splitRuns` 와 같은 규칙).
+ * ========================================================================== */
+
+import type { PlanFootprint, Vertex } from "../../A00_Common/design/design_pipeline_types";
+
+export type FootprintStrokeKind = "road" | "cut" | "fill" | "slope";
+
+export interface FootprintStroke {
+ kind: FootprintStrokeKind;
+ points: Vertex[];
+}
+
+/** footprint 단계 출력 — 계약 칸 + 비탈 끝 점마다 깎기 여부(없을 수 있음). */
+export type FootprintValue = PlanFootprint & {
+ slopeLeftCut?: ReadonlyArray;
+ slopeRightCut?: ReadonlyArray;
+};
+
+function slopeRuns(points: Vertex[], cut: ReadonlyArray | undefined): FootprintStroke[] {
+ if (points.length < 2) return [];
+ if (!cut || cut.length !== points.length) return [{ kind: "slope", points }];
+ const runs: FootprintStroke[] = [];
+ let current: FootprintStroke | null = null;
+ points.forEach((point, index) => {
+ const kind = cut[index] ? "cut" : "fill";
+ if (current?.kind !== kind) {
+ current?.points.push(point);
+ current = { kind, points: [point] };
+ runs.push(current);
+ } else {
+ current.points.push(point);
+ }
+ });
+ return runs.filter((run) => run.points.length >= 2);
+}
+
+/** 그릴 선 — 도로 끝 둘 · 비탈 끝 조각들(2점 미만은 뺌). */
+export function footprintStrokes(footprint: FootprintValue | null): FootprintStroke[] {
+ if (!footprint) return [];
+ const road = [footprint.roadLeft, footprint.roadRight]
+ .filter((points) => points?.length >= 2)
+ .map((points): FootprintStroke => ({ kind: "road", points }));
+ return [
+ ...road,
+ ...slopeRuns(footprint.slopeLeft ?? [], footprint.slopeLeftCut),
+ ...slopeRuns(footprint.slopeRight ?? [], footprint.slopeRightCut),
+ ];
+}
diff --git a/resources/tester/test_346_2_footprint_draw.py b/resources/tester/test_346_2_footprint_draw.py
new file mode 100644
index 000000000..26642f4bf
--- /dev/null
+++ b/resources/tester/test_346_2_footprint_draw.py
@@ -0,0 +1,113 @@
+"""PLAN 346-2 — 노선 편집 평면 발자국 그리기.
+
+가짜 footprint → 그릴 선 수 · 색 갈래(도로 끝 · 깎기 · 쌓기 · 깎기 여부 없음) · 바뀌는 점은 앞뒤가
+함께 가짐 · 편집기 그리기 · [발자국] 단추 · 밝음 · 어두움 색 토큰이 이어져 있음.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+B05 = ROOT / "B05_Profile"
+TS_FILE = B05 / "logic" / "B05_Profile_Footprint_Strokes.ts"
+TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
+
+_RUNNER = """
+import { writeFileSync } from "node:fs";
+import { footprintStrokes } from "./B05_Profile/logic/B05_Profile_Footprint_Strokes.js";
+const line = (y) => [[0, y], [10, y], [20, y], [30, y], [40, y]];
+const base = { roadLeft: line(3), roadRight: line(-3), slopeLeft: line(6), slopeRight: line(-7) };
+const out = {
+ none: footprintStrokes(null),
+ plain: footprintStrokes(base),
+ flagged: footprintStrokes({
+ ...base,
+ slopeLeftCut: [true, true, false, false, true],
+ slopeRightCut: [false, false, false, false, false],
+ }),
+ short: footprintStrokes({ roadLeft: [[0, 0]], roadRight: [], slopeLeft: [], slopeRight: [] }),
+ badFlags: footprintStrokes({ ...base, slopeLeftCut: [true] }),
+};
+writeFileSync(process.argv[2], JSON.stringify(out));
+"""
+
+
+def _run(tmp_path: Path) -> dict:
+ out = tmp_path / "js"
+ subprocess.run( # noqa: S603 — 고정 실행 파일
+ [
+ "node",
+ str(TSC),
+ str(TS_FILE),
+ "--outDir",
+ str(out),
+ "--rootDir",
+ str(ROOT),
+ "--module",
+ "esnext",
+ "--target",
+ "es2022",
+ "--moduleResolution",
+ "bundler",
+ "--ignoreConfig",
+ ],
+ cwd=str(ROOT),
+ check=True,
+ capture_output=True,
+ )
+ for emitted in out.rglob("*.js"):
+ emitted.write_text(
+ re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
+ encoding="utf-8",
+ )
+ (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
+ result = tmp_path / "out.json"
+ subprocess.run( # noqa: S603
+ ["node", str(out / "runner.mjs"), str(result)],
+ cwd=str(ROOT),
+ check=True,
+ capture_output=True,
+ )
+ return json.loads(result.read_text(encoding="utf-8"))
+
+
+def _kinds(strokes: list[dict]) -> list[str]:
+ return [stroke["kind"] for stroke in strokes]
+
+
+@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
+def test_가짜_발자국_선_수와_색_갈래(tmp_path: Path) -> None:
+ out = _run(tmp_path)
+ assert out["none"] == [] and out["short"] == []
+ # 깎기 여부 없음 = 도로 끝 둘 + 비탈 끝 둘(한 색)
+ assert _kinds(out["plain"]) == ["road", "road", "slope", "slope"]
+ assert _kinds(out["badFlags"]) == ["road", "road", "slope", "slope"]
+ # 좌 = 깎기 → 쌓기(끝 한 점 깎기는 2점 미만이라 뺌) · 우 = 쌓기 한 줄
+ flagged = out["flagged"]
+ assert _kinds(flagged) == ["road", "road", "cut", "fill", "fill"]
+ assert flagged[2]["points"] == [[0, 6], [10, 6], [20, 6]] # 바뀌는 점(20)을 앞 조각도 가짐
+ assert flagged[3]["points"] == [[20, 6], [30, 6], [40, 6]]
+ assert flagged[4]["points"] == [[0, -7], [10, -7], [20, -7], [30, -7], [40, -7]]
+
+
+def test_편집기가_발자국을_그리고_단추로_켬끔() -> None:
+ render = (B05 / "B05_Profile_UI_RouteEdit_Render.ts").read_text(encoding="utf-8")
+ assert "drawFootprint(context, toScreen, style, () => drawRouteEditScene(context, scene))" in (
+ render
+ )
+ # 등고선 뒤 · 노선 앞(노선이 발자국 위에 보임)
+ assert render.index("drawFootprint(context") < render.index("scene.expected,")
+ chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
+ assert 'data-act="footprint"' in chrome and "bindFootprintToggle(" in chrome
+ footprint = (B05 / "B05_Profile_UI_RouteEdit_Footprint.ts").read_text(encoding="utf-8")
+ assert 'subscribe("footprint"' in footprint and "let shown = true;" in footprint
+ css = (B05 / "B05_Profile_UI_Style_RouteEdit_Footprint.css").read_text(encoding="utf-8")
+ for token in ("--b05-footprint-road", "--b05-footprint-cut", "--b05-footprint-fill"):
+ assert token in footprint
+ assert css.count(f"{token}:") == 3 # 밝음 · 어두움 · 시스템 어두움