feat(B05 평면 도구줄): 구분선 · 스냅 간격 고르기 · Ctrl+휠 5° 회전 · [기울기] 기본 꺼짐 (390-1 · 2 · 3)

- [측점 스냅] · 간격 | 분석 지형 구분선(분석 지형 숨으면 같이 숨김) · [기울기] 기본 끔
- 스냅 간격 0.1 · 1 · 10 m · 측점 No.(기본) — 프로젝트별 화면 기억 · 스냅 끔이면 흐림 · MC 를 간격 배수에 · 허용 오차 간격 따라(0.1 m = 0.005 m)
- Ctrl+휠 한 칸 5° 회전(단추 15° 그대로 · 손가락 · 트랙패드 벌림 = 확대 · 추천 지도 안 돎) · 도움말 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-17 17:17:51 +09:00
co-authored by Claude Opus 5.5
parent 1b327b3613
commit a108c3ae11
15 changed files with 191 additions and 30 deletions
+12 -3
View File
@@ -56,7 +56,7 @@ function columns(planned, lock = []) {
};
}
function measure(planned) {
function measure(planned, interval = INTERVAL) {
const built = curve.buildEditedPolyline(
planned,
planned.map(() => true),
@@ -74,9 +74,9 @@ function measure(planned) {
return {
curves: built.curves.length,
mcs,
residues: mcs.map((mc) => Math.abs(mc - Math.round(mc / INTERVAL) * INTERVAL)),
residues: mcs.map((mc) => Math.abs(mc - Math.round(mc / interval) * interval)),
innerStations: total.filter((s) => s > 0 && s < length).length,
off: fit.offStationCount(built.vertices, built.curves, INTERVAL),
off: fit.offStationCount(built.vertices, built.curves, interval),
};
}
@@ -96,6 +96,15 @@ console.log(
fromLater: { ...measure(fromLater), moved: moved(ROUTE, fromLater) },
everyLocked: { ...measure(everyLocked), moved: moved(ROUTE, everyLocked) },
again: moved(all, fit.fitCurveStations(columns(all), LIMITS, 1)),
// 390-2 스냅 간격 — 0.1 · 1 · 10 m 배수 · 측점 밖 개수도 같은 간격
steps: Object.fromEntries(
[0.1, 1, 10].map((step) => {
const fitted = fit.fitCurveStations(columns(ROUTE), { ...LIMITS, intervalM: step }, 1);
const offBefore = measure(ROUTE, step).off;
return [step, { offBefore, ...measure(fitted, step), moved: moved(ROUTE, fitted) }];
}),
),
tol: [0.1, 1, 10, 20].map((step) => fit.mcTolM(step)),
noInterval: moved(ROUTE, fit.fitCurveStations(columns(ROUTE), { ...LIMITS, intervalM: 0 }, 1)),
start: {
moved4: fit.fitStart(
@@ -165,8 +165,51 @@ const afterInsert = JSON.parse(JSON.stringify(columns));
edits.spliceNode(columns, 1);
const afterRemove = JSON.parse(JSON.stringify(columns));
// ⑤ Ctrl+휠 = 5° 돌리기(390-3) · 손가락 · 트랙패드 벌림 · 맨 휠 = 확대 · rotateWheel 없으면(추천 지도) 확대
const input = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Input.ts"));
const rotateMod = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Rotate.ts"));
function wheelCase(withRotate, event) {
const handlers = {};
const canvas = {
addEventListener: (name, fn) => (handlers[name] = fn),
getBoundingClientRect: () => ({ left: 0, top: 0, width: 400, height: 300 }),
};
let wheelView = {
width: 400,
height: 300,
scale: 1,
offsetX: 0,
offsetY: 0,
mapRect: { width: 400 },
};
let changes = 0;
const rotation = rotateMod.createMapRotation({
overlay: { querySelector: () => null },
size: () => wheelView,
onChange: () => (changes += 1),
});
input.bindRouteEditNavigation({
canvas,
getView: () => wheelView,
setView: (next) => (wheelView = next),
getMeta: () => null,
rotateWheel: withRotate ? rotation.wheel : undefined,
draw: () => {},
});
handlers.wheel({ clientX: 200, clientY: 150, preventDefault: () => {}, ...event });
return { deg: (rotation.radians() * 180) / Math.PI, scale: wheelView.scale, changes };
}
const wheel = {
ctrlDown: wheelCase(true, { ctrlKey: true, deltaY: 100 }),
ctrlUp: wheelCase(true, { ctrlKey: true, deltaY: -100 }),
trackpad: wheelCase(true, { ctrlKey: true, deltaY: -4 }),
plain: wheelCase(true, { ctrlKey: false, deltaY: -100 }),
recommend: wheelCase(false, { ctrlKey: true, deltaY: 100 }),
};
process.stdout.write(
JSON.stringify({
wheel,
saved,
again,
readBack,
@@ -65,6 +65,21 @@ def test_맞춘_선은_다시_맞춰도_그대로_간격_0_은_안_맞춤(got) -
assert max(got["again"]) == 0 and max(got["noInterval"]) == 0
@pytest.mark.parametrize("step", ["0.1", "1", "10"])
def test_스냅_간격_배수에_맞춤(got, step) -> None:
"""390-2 — MC 를 고른 간격 배수에 · 측점 밖 개수도 같은 간격 · 밀림은 간격 절반 안."""
size = float(step)
case = got["steps"][step]
assert case["offBefore"] == 5 and case["off"] == 0 and case["curves"] == 5
assert max(case["residues"]) <= min(TOL, size / 20)
assert max(case["moved"]) <= size / 2 + 1e-6
assert case["moved"][0] == 0 and case["moved"][-1] == 0
def test_허용_오차는_간격_따라(got) -> None:
assert got["tol"] == pytest.approx([0.005, 0.05, 0.05, 0.05])
def test_시작_자리는_고친_꺾임점_앞(got) -> None:
start = got["start"]
assert start["moved4"] == 3 and start["radius2"] == 1
@@ -1,6 +1,6 @@
"""381-2 B05 평면 [측점] [기울기] 켬끔(요구 241-11).
- 단추 = 절/성토선 왼쪽 · 켬끔 모듈(기본 켬 · 페이지 동안)
- 단추 = 절/성토선 왼쪽 · 켬끔 모듈(기본 [측점] 켬 · [기울기] 끔(390-1 요구 242-17) · 페이지 동안)
- [측점] 끔 = 눈금 · 번호 안 그림 · 눈금 집기 안 함(라벨 0개 = 전부 집힘 함정) · 시점 · 종점 이름표 남김
- [기울기] 끔 = 기울기 라벨(높이 묻기) 안 부름
"""
@@ -14,9 +14,9 @@ def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
def test_켬끔_모듈_기본_켬():
def test_켬끔_모듈_기본_측점_켬_기울기_끔():
marks = _read("B05_Profile_UI_RouteEdit_Marks.ts")
assert "{ stations: true, slopes: true }" in marks
assert "{ stations: true, slopes: false }" in marks
assert "scheduleRedraw();" in marks
assert "export function scheduleRedraw" in _read("B05_Profile_UI_RouteEdit_Footprint.ts")
+10 -4
View File
@@ -208,7 +208,7 @@ def test_노선_추천_다듬기_23_3():
return open(os.path.join(page, name), encoding="utf-8").read()
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
# 366-2 — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 분석 지형(맨 오른쪽)
# 366-2 — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 분석 지형(맨 오른쪽)
bar = chrome[
chrome.index("b05-routeedit__tools") : chrome.index('<div class="b05-routeedit__hint">')
]
@@ -229,14 +229,20 @@ def test_노선_추천_다듬기_23_3():
'data-act="measure"',
"|",
'data-act="station-snap"',
'class="b05-routeedit__terrain"',
'data-act="snap-step"', # 390-2 스냅 간격 = [측점 스냅] 오른쪽
"|", # 390-1 [측점 스냅] | 분석 지형
'class="b05-routeedit__terrain" hidden',
]
at = 0
for mark in order:
key = "b05-route__view-separator" if mark == "|" else mark
at = bar.index(key, at) + 1
assert bar.count("b05-route__view-separator") == 4
assert chrome.count("b05-route__view-separator") == 4
assert bar.count("b05-route__view-separator") == 5
assert chrome.count("b05-route__view-separator") == 5
# 분석 지형이 숨으면 앞 구분선도 숨김 · 스냅 끔이면 간격 고르기 흐림
style = read("B05_Profile_UI_Style_RouteEdit.css")
assert ".b05-route__view-separator:has(+ .b05-routeedit__terrain[hidden])" in style
assert '[data-act="station-snap"][aria-pressed="false"] + .b05-routeedit__snap-step' in style
assert "b05-routeedit__spin" not in chrome # 회전 = 도구줄(추천 모달 CSS 는 남음)
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
@@ -84,3 +84,13 @@ def test_숨김_크기_0_뒤_다시_보이면_그대로(results):
assert back["cx"] == pytest.approx(kept["cx"], abs=1e-6)
assert back["cy"] == pytest.approx(kept["cy"], abs=1e-6)
assert back["pxPerMeter"] == pytest.approx(kept["pxPerMeter"], rel=1e-9)
def test_Ctrl_휠은_5도_돌리기_벌림과_맨_휠은_확대(results):
"""390-3 — 마우스 Ctrl+휠 한 칸 = 5° · 트랙패드 벌림 · 맨 휠 = 확대 · 추천 지도(rotateWheel 없음) = 확대."""
w = results["wheel"]
assert w["ctrlDown"] == {"deg": pytest.approx(5), "scale": 1, "changes": 1}
assert w["ctrlUp"]["deg"] == pytest.approx(-5) and w["ctrlUp"]["scale"] == 1
for case in ("trackpad", "plain", "recommend"):
assert w[case]["deg"] == 0 and w[case]["changes"] == 0, case
assert w[case]["scale"] != 1, case