Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_2
This commit is contained in:
@@ -68,9 +68,9 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
params.applyEdit(
|
||||
value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.",
|
||||
);
|
||||
const message = value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.";
|
||||
params.applyEdit(message);
|
||||
noteShortfall(message, "반지름");
|
||||
},
|
||||
onArcLength: (value) => {
|
||||
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
|
||||
@@ -80,9 +80,10 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveArc[picked] = value;
|
||||
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
||||
params.applyEdit(
|
||||
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
|
||||
);
|
||||
const message =
|
||||
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.";
|
||||
params.applyEdit(message);
|
||||
noteShortfall(message, "곡선 길이");
|
||||
},
|
||||
onLock: (lock) => {
|
||||
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
|
||||
@@ -122,6 +123,23 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
params.container,
|
||||
);
|
||||
|
||||
/** 넣은 값을 다 못 그렸으면 상태줄 글을 넣은 값 → 그린 값 · 까닭으로(398-2 · 상태줄 두 줄 안) — 접선은
|
||||
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다. */
|
||||
function noteShortfall(message: string, name: "반지름" | "곡선 길이"): void {
|
||||
const { picked, curveRadius, curveInfo, nodeInfo } = params.state();
|
||||
const asked = curveRadius[picked];
|
||||
const drawn = curveInfo.find((entry) => entry.node_first === picked);
|
||||
if (asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05) return;
|
||||
const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1;
|
||||
const m = (value: number): string => `${Math.round(value * 10) / 10}m`;
|
||||
const status = params.container.querySelector<HTMLElement>(".b05-routeedit__status");
|
||||
if (!status) return;
|
||||
status.textContent = (status.textContent ?? "").replace(
|
||||
message,
|
||||
`${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
|
||||
function sync(): void {
|
||||
const {
|
||||
@@ -169,7 +187,8 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
return;
|
||||
}
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
||||
// 칸 = 그린 R(398-2) — 넣은 값을 그대로 보이면 접선 자리에 눌린 것이 안 보임.
|
||||
const shown = pickedCurve?.radius_m ?? curveRadius[picked] ?? null;
|
||||
const innerAngle = nodeInfo[picked]?.inner_angle_deg ?? null;
|
||||
const optional = curveOptionalAt(innerAngle);
|
||||
const deflection = deflectionRad(innerAngle);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* 398-2 꺾임점 창 반지름 · 곡선 길이 넣기(요구 249) — `_CurveBar` 배선을 Node 로 돌려 JSON 한 줄
|
||||
* (`test_398_2_curve_input.py` 가 판정). 창(`_Label`)은 손잡이만 잡는 가짜 · 편집 창 `applyEdit` 는
|
||||
* 같은 길(스냅 켬 = `fitCurveStations` → 길이 고정 R · 하한 → `buildEditedPolyline`)로 흉내.
|
||||
* node helper_398_2_curve_input.cjs
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B05 = path.join(ROOT, "B05_Profile");
|
||||
const cache = new Map();
|
||||
let handlers = null;
|
||||
const shows = [];
|
||||
const STUBS = {
|
||||
"./B05_Profile_UI_RouteEdit_Label": {
|
||||
createCurveLabel: (given) => {
|
||||
handlers = given;
|
||||
return { show: (state) => shows.push(state), hide: () => {}, destroy: () => {} };
|
||||
},
|
||||
centerDirectionOf: () => null,
|
||||
},
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) =>
|
||||
STUBS[request] ??
|
||||
(request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request));
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts"));
|
||||
const edits = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Edits.ts"));
|
||||
const fit = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StationFit.ts"));
|
||||
const bar = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_CurveBar.ts"));
|
||||
|
||||
// IP1 = 앞뒤 직선 길어 R 60 들어감 · IP2 = 뒤 직선 16 m 라 접선 8 m 까지(큰 R 눌림)
|
||||
const ROUTE = [
|
||||
[0, 0],
|
||||
[120, 0],
|
||||
[220, 70],
|
||||
[236, 70],
|
||||
[340, 0],
|
||||
];
|
||||
const LIMITS = { minRadiusM: 12, limitRadiusM: 12, limitArcM: 5, intervalM: 20 };
|
||||
|
||||
function editor(snap) {
|
||||
const n = ROUTE.length;
|
||||
const state = {
|
||||
picked: -1,
|
||||
planned: ROUTE.map((v) => [v[0], v[1]]),
|
||||
nodeInfo: [],
|
||||
curveInfo: [],
|
||||
curveOn: Array(n).fill(true),
|
||||
curveRadius: Array(n).fill(null),
|
||||
curveLock: Array(n).fill(null),
|
||||
curveArc: Array(n).fill(null),
|
||||
apexLock: Array(n).fill(false),
|
||||
limitRadiusM: LIMITS.limitRadiusM,
|
||||
limitArcM: LIMITS.limitArcM,
|
||||
};
|
||||
const status = { textContent: "" };
|
||||
const markEdited = () => {
|
||||
edits.applyArcLocks(state.planned, state.curveLock, state.curveArc, state.curveRadius);
|
||||
edits.applyCurveLimits(
|
||||
state.planned,
|
||||
state.curveOn,
|
||||
state.curveRadius,
|
||||
LIMITS.limitRadiusM,
|
||||
LIMITS.limitArcM,
|
||||
);
|
||||
const built = curve.buildEditedPolyline(
|
||||
state.planned,
|
||||
state.curveOn,
|
||||
state.curveRadius,
|
||||
LIMITS.minRadiusM,
|
||||
);
|
||||
state.line = built.vertices;
|
||||
state.curveInfo = built.curves;
|
||||
state.nodeInfo = built.nodes;
|
||||
};
|
||||
let made = null;
|
||||
const applyEdit = (message) => {
|
||||
if (snap) state.planned = fit.fitCurveStations(state, LIMITS, 1);
|
||||
markEdited();
|
||||
made.sync();
|
||||
status.textContent = `예상노선 — ${message} 꼬리`;
|
||||
};
|
||||
made = bar.createCurveBar({
|
||||
canvas: { getBoundingClientRect: () => ({ left: 0, top: 0, right: 800, bottom: 600 }) },
|
||||
container: { querySelector: (sel) => (sel === ".b05-routeedit__status" ? status : null) },
|
||||
state: () => state,
|
||||
toScreen: (v) => [v[0], v[1]],
|
||||
applyEdit,
|
||||
onUnselect: () => {},
|
||||
});
|
||||
markEdited();
|
||||
const drawn = (k) => state.curveInfo.find((c) => c.node_first === k) ?? null;
|
||||
const arcOf = (k) => {
|
||||
const c = drawn(k);
|
||||
return c ? c.radius_m * edits.deflectionRad(c.inner_angle_deg) : null;
|
||||
};
|
||||
const mc = (k) => {
|
||||
const c = drawn(k);
|
||||
if (!c) return null;
|
||||
const [[a, b]] = loadTs(
|
||||
path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Frame.ts"),
|
||||
).curveSpans(state.line, [c]);
|
||||
return (a + b) / 2;
|
||||
};
|
||||
const pick = (k) => {
|
||||
state.picked = k;
|
||||
made.sync();
|
||||
};
|
||||
const lastShow = () => shows[shows.length - 1];
|
||||
return { state, status, handlers: () => handlers, drawn, arcOf, mc, pick, lastShow, markEdited };
|
||||
}
|
||||
|
||||
const round = (v) => (v === null ? null : Math.round(v * 1000) / 1000);
|
||||
const out = {};
|
||||
|
||||
for (const snap of [false, true]) {
|
||||
const key = snap ? "snapOn" : "snapOff";
|
||||
const ed = editor(snap);
|
||||
const h = ed.handlers();
|
||||
const row = {};
|
||||
// IP1 — 반지름 40 → 60 · 곡선 길이 18 → 25
|
||||
ed.pick(1);
|
||||
h.onRadius(40);
|
||||
row.r40 = round(ed.drawn(1).radius_m);
|
||||
h.onRadius(60);
|
||||
row.r60 = round(ed.drawn(1).radius_m);
|
||||
row.r60Shown = round(ed.lastShow().radiusShown);
|
||||
row.r60Status = ed.status.textContent;
|
||||
row.r60Mc = round(ed.mc(1));
|
||||
h.onArcLength(18);
|
||||
row.l18 = round(ed.arcOf(1));
|
||||
h.onArcLength(25);
|
||||
row.l25 = round(ed.arcOf(1));
|
||||
row.l25Shown = round(ed.lastShow().arcLengthShown);
|
||||
row.l25Status = ed.status.textContent;
|
||||
// IP2 — 접선 자리 모자람(넣은 값 · 그린 값 · 까닭)
|
||||
ed.pick(2);
|
||||
h.onRadius(60);
|
||||
row.capR = round(ed.drawn(2).radius_m);
|
||||
row.capShown = round(ed.lastShow().radiusShown);
|
||||
row.capStatus = ed.status.textContent;
|
||||
h.onArcLength(40);
|
||||
row.capArcStatus = ed.status.textContent;
|
||||
h.onRadius(null);
|
||||
row.autoStatus = ed.status.textContent;
|
||||
out[key] = row;
|
||||
}
|
||||
|
||||
// 「고정」 — 길이 고정 켬 = 교각이 바뀌어도 그 길이 · 끔 = 반지름이 남고 길이가 따라 바뀜
|
||||
for (const lock of [true, false]) {
|
||||
const ed = editor(false);
|
||||
const h = ed.handlers();
|
||||
ed.pick(1);
|
||||
h.onArcLength(25);
|
||||
if (lock) h.onLock("arc");
|
||||
ed.state.planned[2] = [220, 50]; // IP1 교각이 바뀜
|
||||
ed.markEdited();
|
||||
out[lock ? "arcLocked" : "arcFree"] = {
|
||||
arc: round(ed.arcOf(1)),
|
||||
radius: round(ed.drawn(1).radius_m),
|
||||
};
|
||||
}
|
||||
{
|
||||
const ed = editor(false);
|
||||
const h = ed.handlers();
|
||||
ed.pick(1);
|
||||
h.onRadius(60);
|
||||
h.onLock("radius");
|
||||
ed.state.planned[2] = [220, 50];
|
||||
ed.markEdited();
|
||||
out.radiusLocked = { radius: round(ed.drawn(1).radius_m), lock: ed.state.curveLock[1] };
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,76 @@
|
||||
"""398-2 꺾임점 창 반지름 · 곡선 길이 넣기(요구 249) — 넣은 값으로 곡선이 바뀜 ·
|
||||
스냅 켬 = 맞춤 뒤 값 · 접선 자리 모자라 눌리면 칸 = 그린 값 · 상태줄 = 넣은 값 · 그린 값 · 까닭 ·
|
||||
「고정」 켬 = 그 값 지킴.
|
||||
|
||||
까닭: 접선은 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝)라 큰 R 이 조용히 눌렸는데
|
||||
창 칸은 넣은 값을 그대로 보여 바뀐 게 없어 보였음.
|
||||
|
||||
TS 셈은 `helper_398_2_curve_input.cjs`(Node · `_CurveBar` 배선 그대로).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_398_2_curve_input.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).with_name("helper_398_2_curve_input.cjs")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
|
||||
def test_반지름_40_60_곡선이_바뀜(got, snap) -> None:
|
||||
row = got[snap]
|
||||
assert row["r40"] == pytest.approx(40) and row["r60"] == pytest.approx(60)
|
||||
assert row["r60Shown"] == pytest.approx(60)
|
||||
assert "반지름을 바꿨습니다." in row["r60Status"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
|
||||
def test_곡선_길이_18_25_곡선이_바뀜(got, snap) -> None:
|
||||
row = got[snap]
|
||||
tol = 0.1 if snap == "snapOn" else 1e-6 # 스냅 켬 = IP 맞춤으로 교각이 조금 바뀐 뒤 값
|
||||
assert row["l18"] == pytest.approx(18, abs=tol) and row["l25"] == pytest.approx(25, abs=tol)
|
||||
assert row["l25Shown"] == pytest.approx(row["l25"])
|
||||
assert "곡선 길이를 바꿨습니다." in row["l25Status"]
|
||||
|
||||
|
||||
def test_스냅_켬_MC_가_측점에(got) -> None:
|
||||
assert got["snapOn"]["r60Mc"] == pytest.approx(120, abs=0.05) # 20 m 간격
|
||||
assert got["snapOff"]["r60Mc"] != pytest.approx(120, abs=0.05)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
|
||||
def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -> None:
|
||||
row = got[snap]
|
||||
assert row["capR"] < 60 and row["capShown"] == pytest.approx(row["capR"])
|
||||
want = f"반지름 60m → {round(row['capR'], 1)}m(앞뒤 직선이 짧아 접선 자리 모자람)"
|
||||
assert want in row["capStatus"]
|
||||
assert "곡선 길이 40m → " in row["capArcStatus"]
|
||||
assert "반지름을 자동으로 되돌렸습니다." in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음
|
||||
|
||||
|
||||
def test_고정(got) -> None:
|
||||
assert got["arcLocked"]["arc"] == pytest.approx(25) # 교각이 바뀌어도 길이 그대로
|
||||
assert got["arcFree"]["arc"] != pytest.approx(25, abs=0.1)
|
||||
assert got["radiusLocked"] == {"radius": pytest.approx(60), "lock": "radius"}
|
||||
|
||||
|
||||
def test_편집기_줄_수_그대로() -> None:
|
||||
edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
|
||||
assert edit.count("\n") <= 695
|
||||
Reference in New Issue
Block a user