From b1a1658f97979737b144f0b56220523502ce4902 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 20:51:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(562-1):=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=3D=20=EB=A7=88=EC=9A=B0=EC=8A=A4=EC=97=90?= =?UTF-8?q?=20=EA=B0=80=EC=9E=A5=20=EA=B0=80=EA=B9=8C=EC=9A=B4=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EC=A7=A7=EC=9D=80=20=EC=B2=AB=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EB=8C=80=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 반직선 위 |기울기| 목표 자리 전부 모아 마우스 거리 가까운 자리 선택(없으면 없음) - 셈 짝 TS ↔ Py 같이 · 거울 시험에 여러 자리 경우 추가 Co-Authored-By: Claude Sonnet 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Draw.py | 40 ++++++++++------- .../logic/D03_PlanAnalysis_Draw.ts | 43 +++++++++++++------ resources/tester/test_562_1_plan_draw.py | 33 +++++++++++++- 3 files changed, 86 insertions(+), 30 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py b/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py index d30968ecf..c494d12c2 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py @@ -65,8 +65,9 @@ def fixed_slope_point( height_at: HeightAt, max_m: float = 1000, ) -> Vertex | None: - """기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) · - `max_m` 안에 없거나 지형 밖에 닿으면 None.""" + """기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 자리 중 마우스까지 거리에 가장 + 가까운 자리(1 m 걸음 · 이분 · 같으면 앞 자리) · `max_m` 안에 없으면 None · 지형 밖에 닿으면 + 거기까지의 자리만.""" d = segment_length(start, cursor) z0 = height_at(start[0], start[1]) if d < EPS or not _finite(z0): @@ -82,33 +83,42 @@ def fixed_slope_point( p = at(t) return abs(height_at(p[0], p[1]) - z0) - grade * t + roots: list[float] = [] lo = 0.0 flo = math.nan i = 1 while i * SLOPE_STEP_M <= max_m: t = i * SLOPE_STEP_M + i += 1 ft = f(t) if not _finite(ft): - return None + break if ft == 0: - return at(t) - if i > 1 and (flo < 0) != (ft < 0): - hi = t + roots.append(t) + lo = t + flo = math.nan + continue + if not math.isnan(flo) and (flo < 0) != (ft < 0): + a, b, fa = lo, t, flo + ok = True for _ in range(BISECT): - mid = (lo + hi) / 2 + mid = (a + b) / 2 fm = f(mid) if not _finite(fm): - return None - if (fm < 0) == (flo < 0): - lo = mid - flo = fm + ok = False + break + if (fm < 0) == (fa < 0): + a = mid + fa = fm else: - hi = mid - return at((lo + hi) / 2) + b = mid + if ok: + roots.append((a + b) / 2) lo = t flo = ft - i += 1 - return None + if not roots: + return None + return at(min(roots, key=lambda r: abs(r - d))) def compass_candidates( diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts index daa3a7fcd..7ee346d67 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts @@ -44,8 +44,9 @@ export function fixedLengthPoint(from: Vertex, cursor: Vertex, lengthM: number): ]; } -/** 기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) · - * `maxM` 안에 없거나 지형 밖에 닿으면 null. */ +/** 기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 자리 중 마우스까지 거리에 가장 + * 가까운 자리(1 m 걸음 · 이분 · 같으면 앞 자리) · `maxM` 안에 없으면 null · 지형 밖에 닿으면 거기까지의 + * 자리만. */ export function fixedSlopePoint( from: Vertex, cursor: Vertex, @@ -64,30 +65,44 @@ export function fixedSlopePoint( const p = at(t); return Math.abs(heightAt(p[0], p[1]) - z0) - grade * t; }; + const roots: number[] = []; let lo = 0; let flo = NaN; for (let i = 1; i * SLOPE_STEP_M <= maxM; i += 1) { const t = i * SLOPE_STEP_M; const ft = f(t); - if (!Number.isFinite(ft)) return null; - if (ft === 0) return at(t); - if (i > 1 && flo < 0 !== ft < 0) { - let hi = t; + if (!Number.isFinite(ft)) break; + if (ft === 0) { + roots.push(t); + lo = t; + flo = NaN; + continue; + } + if (!Number.isNaN(flo) && flo < 0 !== ft < 0) { + let a = lo; + let b = t; + let fa = flo; + let ok = true; for (let k = 0; k < BISECT; k += 1) { - const mid = (lo + hi) / 2; + const mid = (a + b) / 2; const fm = f(mid); - if (!Number.isFinite(fm)) return null; - if (fm < 0 === flo < 0) { - lo = mid; - flo = fm; - } else hi = mid; + if (!Number.isFinite(fm)) { + ok = false; + break; + } + if (fm < 0 === fa < 0) { + a = mid; + fa = fm; + } else b = mid; } - return at((lo + hi) / 2); + if (ok) roots.push((a + b) / 2); } lo = t; flo = ft; } - return null; + let best: number | null = null; + for (const t of roots) if (best === null || Math.abs(t - d) < Math.abs(best - d)) best = t; + return best === null ? null : at(best); } /** 둘 다(양각기) — 반지름 `lengthM` 원 위에서 |기울기| = 목표인 후보들(방위 표본 · 이분 · 동쪽부터 diff --git a/resources/tester/test_562_1_plan_draw.py b/resources/tester/test_562_1_plan_draw.py index e42bff53d..5e2ea52d7 100644 --- a/resources/tester/test_562_1_plan_draw.py +++ b/resources/tester/test_562_1_plan_draw.py @@ -37,6 +37,10 @@ def plane(x: float, y: float) -> float: return 0.1 * x +def bumps(x: float, y: float) -> float: + return 5 * math.sin(x / 10) + + LINE = [[0.0, 0.0], [30.0, 40.0], [30.0, 100.0], [90.0, 180.0]] # 50 + 60 + 100 = 210 m # (함수 이름, 인자) — 높이 자리는 "hill" · "plane" 글자로 넘김 @@ -53,6 +57,9 @@ CASES = [ ("fixedSlopePoint", [[100, 30], [130, 70], 12, "hill"]), ("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill", 100]), ("fixedSlopePoint", [[600, 0], [700, 0], 1, "hill"]), + ("fixedSlopePoint", [[0, 0], [30, 0], 9, "bumps"]), + ("fixedSlopePoint", [[0, 0], [60, 0], 9, "bumps"]), + ("fixedSlopePoint", [[0, 0], [200, 0], 9, "bumps"]), ("compassCandidates", [[0, 0], 50, 5, "plane"]), ("compassCandidates", [[200, 10], 60, 15, "hill"]), ("compassCandidates", [[0, 0], 50, 50, "plane"]), @@ -77,6 +84,7 @@ import * as draw from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js"; const heights = { hill: (x, y) => (x > 800 ? NaN : 0.0005 * x * x + 3 * Math.sin(y / 40)), plane: (x, y) => 0.1 * x, + bumps: (x, y) => 5 * Math.sin(x / 10), }; const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); const out = cases.map(([name, args]) => @@ -85,7 +93,7 @@ const out = cases.map(([name, args]) => writeFileSync(process.argv[3], JSON.stringify(out)); """ -_HEIGHTS = {"hill": hill, "plane": plane} +_HEIGHTS = {"hill": hill, "plane": plane, "bumps": bumps} _PY = { "segmentLength": draw.segment_length, "groundSlopePct": draw.ground_slope_pct, @@ -187,6 +195,29 @@ def test_hand_values() -> None: assert draw.compass_point((0, 0), (10, 40), 50, 50, plane) is None +def test_fixed_slope_nearest_to_cursor() -> None: + # 5·sin(x/10) 에서 9 % 자리가 여럿 — 마우스 가까운 자리(짧은 첫 자리 아님) + def roots() -> list[float]: + out, prev = [], None + for i in range(1, 100001): + t = i * 0.01 + f = abs(bumps(t, 0)) - 0.09 * t + if prev is not None and (prev < 0) != (f < 0): + out.append(t) + prev = f + return out + + rs = roots() + assert len(rs) >= 3 + for cx in (30, 60, 200): + want = min(rs, key=lambda r: abs(r - cx)) + got = draw.fixed_slope_point((0, 0), (cx, 0), 9, bumps) + assert got is not None and got[0] == pytest.approx(want, abs=0.02) + first = draw.fixed_slope_point((0, 0), (30, 0), 9, bumps) + far = draw.fixed_slope_point((0, 0), (200, 0), 9, bumps) + assert first is not None and far is not None and far[0] > first[0] + 20 + + def test_line_values() -> None: assert draw.line_length(LINE) == 210 assert draw.point_at_distance(LINE, -5) == (0, 0)