From 498d48fc7f0ecc3e658d556b71dfd9f0467bc961 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:43:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EB=B0=9C=EC=9E=90=EA=B5=AD):=20346-?= =?UTF-8?q?2=20=ED=8F=89=EB=A9=B4=20=EB=B0=9C=EC=9E=90=EA=B5=AD=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=E2=80=94=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=BA=94=EB=B2=84=EC=8A=A4=EC=97=90=20?= =?UTF-8?q?=EA=B0=80=EA=B2=8C=20=ED=82=A4=20footprint(=EB=8F=84=EB=A1=9C?= =?UTF-8?q?=20=EB=81=9D=20=EC=A2=8C=20=C2=B7=20=EC=9A=B0=20=C2=B7=20?= =?UTF-8?q?=EB=B9=84=ED=83=88=20=EB=81=9D=20=EA=B9=8E=EA=B8=B0=20=EB=B6=89?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=8C=93=EA=B8=B0=20=ED=8C=8C=EB=9E=91=20?= =?UTF-8?q?=C2=B7=20=EB=B0=9D=EC=9D=8C=20=C2=B7=20=EC=96=B4=EB=91=90?= =?UTF-8?q?=EC=9B=80=20=ED=86=A0=ED=81=B0)=20=C2=B7=20=EB=93=B1=EA=B3=A0?= =?UTF-8?q?=EC=84=A0=20=EC=9C=84=20=C2=B7=20=EB=85=B8=EC=84=A0=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20=C2=B7=20=EA=B0=80=EA=B2=8C=20=EA=B5=AC=EB=8F=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EB=80=90=20=EB=95=8C=EB=A7=8C=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EA=B7=B8=EB=A6=BC(=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=9E=84=EB=A7=88=EB=8B=A4=20=ED=95=9C=20=EB=B2=88)=20=C2=B7?= =?UTF-8?q?=20=EB=8F=84=EA=B5=AC=EC=A4=84=20[=EB=B0=9C=EC=9E=90=EA=B5=AD]?= =?UTF-8?q?=20=EC=BC=AC=EB=81=94(=EA=B8=B0=EB=B3=B8=20=EC=BC=AC=20=C2=B7?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=8F=99=EC=95=88)=20=C2=B7?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../B05_Profile_UI_RouteEdit_Chrome.ts | 4 + .../B05_Profile_UI_RouteEdit_Footprint.ts | 93 ++++++++++++++ .../B05_Profile_UI_RouteEdit_Render.ts | 3 + ...5_Profile_UI_Style_RouteEdit_Footprint.css | 20 ++++ .../logic/B05_Profile_Footprint_Strokes.ts | 54 +++++++++ resources/tester/test_346_2_footprint_draw.py | 113 ++++++++++++++++++ 6 files changed, 287 insertions(+) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Footprint.ts create mode 100644 B05_Profile/B05_Profile_UI_Style_RouteEdit_Footprint.css create mode 100644 B05_Profile/logic/B05_Profile_Footprint_Strokes.ts create mode 100644 resources/tester/test_346_2_footprint_draw.py 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 # 밝음 · 어두움 · 시스템 어두움