/* 390-5 스냅 간격 = 조작 단위 — 방향키 걸음(`KeyNudge.arrowStep`) · 끌기 미리보기(`StationFit.fitDrag`) · * MC 곡선 측점(`Frame.curveStations` · 280-4) · 눈금 글 `stationLabel`)를 Node 로 돌려 JSON 한 줄 * (`test_390_5_snap_step_ops.py` 가 판정 · 파이썬 짝 거울은 그쪽). * node helper_390_5_snap_step_ops.cjs '<{"grid":[…],"spans":[[bc,ec]…]}>' */ const fs = require("fs"); const path = require("path"); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); const ROOT = path.join(__dirname, "..", ".."); const { loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => request.startsWith(".") ? loadTs(path.join(path.dirname(full), `${request}.ts`)) : require(request), }); const LOGIC = path.join(ROOT, "B05_Profile", "logic"); const fit = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_StationFit.ts")); const curve = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Curve.ts")); const frame = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Frame.ts")); const nudge = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_KeyNudge.ts")); const station = loadTs(path.join(ROOT, "common_util", "common_util_station.ts")); const INTERVAL = 20; const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: INTERVAL }; const ROUTE = [ [0, 0], [93, 7], [171, 61], [262, 48], [337, 112], [431, 101], [503, 160], ]; const STEPS = [10, INTERVAL]; // 280-4 — 0.1 · 1 m 지움 function columns(planned) { const n = planned.length; return { planned, curveOn: planned.map(() => true), curveRadius: planned.map(() => null), curveLock: planned.map(() => null), curveArc: planned.map(() => null), apexLock: planned.map((_, i) => i === 0 || i === n - 1), }; } const build = (planned) => curve.buildEditedPolyline( planned, planned.map(() => true), planned.map(() => null), LIMITS.minRadiusM, ); function mcs(planned) { const built = build(planned); return frame.curveSpans(built.vertices, built.curves).map(([a, b]) => (a + b) / 2); } const residue = (mc, step) => Math.abs(mc - Math.round(mc / step) * step); const moved = (a, b) => Math.max(...a.map((v, i) => Math.hypot(v[0] - b[i][0], v[1] - b[i][1]))); // ① 방향키 — 화면 축이 30° 돌고 1 px = 0.5 m 인 지도 const turn = Math.PI / 6; const metricAt = (x, y) => [ 100 + 0.5 * (x * Math.cos(turn) - y * Math.sin(turn)), 200 + 0.5 * (x * Math.sin(turn) + y * Math.cos(turn)), ]; const len = ([x, y]) => Math.hypot(x, y); const keys = { off: len(nudge.arrowStep("ArrowUp", false, metricAt)), offShift: len(nudge.arrowStep("ArrowUp", true, metricAt)), snap: Object.fromEntries( STEPS.map((step) => [ step, { plain: len(nudge.arrowStep("ArrowRight", false, metricAt, step)), shift: len(nudge.arrowStep("ArrowRight", true, metricAt, step)), }, ]), ), }; // ② 끌기 미리보기 — 측점 No. 로 맞춘 노선에서 IP 3 을 끌어 감(간격별) const base = fit.fitCurveStations(columns(ROUTE), LIMITS, 1); const DRAG = 3; const mouse = [base[DRAG][0] + 7.3, base[DRAG][1] + 4.1]; const drag = Object.fromEntries( STEPS.map((step) => { const limits = { ...LIMITS, intervalM: step }; const now = base.map((v, i) => (i === DRAG ? mouse : v)); const preview = fit.fitDrag(columns(now), base, DRAG, limits); // 앞 프레임이 다른 자리를 이미 밀어 둔 상태에서도 같은 값(쌓이지 않음) const stale = preview.map((v, i) => (i === DRAG ? mouse : [v[0] + 0.7, v[1] - 0.4])); const again = fit.fitDrag(columns(stale), base, DRAG, limits); // 놓을 때 정본 맞춤(`applyEdit` = fitStart → fitCurveStations)이 미리보기를 안 바꿈 const from = fit.fitStart(columns(base), columns(preview)); const release = fit.fitCurveStations(columns(preview), limits, from); const after = mcs(preview); const built = build(preview); return [ step, { residues: after.map((mc) => residue(mc, step)), tol: fit.mcTolM(step), off: fit.offStationCount(built.vertices, built.curves, step), dragMoved: Math.hypot(preview[DRAG][0] - mouse[0], preview[DRAG][1] - mouse[1]), stale: moved(preview, again), release: moved(preview, release), before: moved(base.slice(0, DRAG - 1), preview.slice(0, DRAG - 1)), }, ]; }), ); // ④ 끌기 훑기(441) — 편집 창 끌기 차례(`_Pointer`) 그대로: 하한 막힘 · 미리보기 측점 밖이 누를 때보다 늘면 // 그 걸음 되돌림 · 놓을 때 정본 맞춤(`applyEdit`). 측점 No. · IP 셋 × 12 방향 × 3 거리 · 고정 R(곡선표 반경 못박음). const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts")); const copy = (c) => ({ ...c, planned: c.planned.map(([x, y]) => [x, y]), curveRadius: [...c.curveRadius], }); function sweep(fixedR) { const start = columns(fit.fitCurveStations(columns(ROUTE), LIMITS, 1)); if (fixedR) start.curveRadius = edits .rebuildLine(copy(start), LIMITS) .nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100)); const floor = (built) => edits.curveShortfalls(built.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM); const off = (built) => fit.offStationCount(built.vertices, built.curves, INTERVAL); const tally = { drags: 0, released: 0, offAfter: 0, strayed: 0, worst: [] }; for (const K of [2, 3, 4]) for (let deg = 0; deg < 360; deg += 30) for (const reach of [9, 17, 26]) { const hist = copy(start); let st = copy(hist); let built = edits.rebuildLine(st, LIMITS); const offBefore = off(built); let raw = null; let movedOnce = false; const preview = () => { raw = st.planned.map(([x, y]) => [x, y]); st = { ...st, planned: fit.fitDrag(st, hist.planned, K, LIMITS) }; built = edits.rebuildLine(st, LIMITS); }; const p0 = hist.planned[K]; for (let t = 0.5; t <= reach + 1e-9; t += 0.5) { if (raw) raw.forEach((v, i) => (st.planned[i] = v)); built = edits.rebuildLine(st, LIMITS); const before = floor(built); const previous = st.planned[K]; st.planned[K] = [ p0[0] + Math.cos((deg * Math.PI) / 180) * t, p0[1] + Math.sin((deg * Math.PI) / 180) * t, ]; built = edits.rebuildLine(st, LIMITS); if (edits.shortfallCrossed(before, floor(built))) { st.planned[K] = previous; preview(); continue; } preview(); if (off(built) > offBefore) { raw.forEach((v, i) => (st.planned[i] = v)); st.planned[K] = previous; preview(); tally.strayed += 1; continue; } movedOnce = true; } tally.drags += 1; if (!movedOnce) continue; built = edits.rebuildLine(st, LIMITS); if (edits.floorStop(hist, st.planned, built.nodes, LIMITS)) continue; const from = fit.fitStart(hist, st); st = { ...st, planned: fit.fitCurveStations(st, LIMITS, from) }; built = edits.rebuildLine(st, LIMITS); tally.released += 1; const left = fit.offStationMcs(built.vertices, built.curves, INTERVAL); if (left.length) { tally.offAfter += 1; if (tally.worst.length < 5) tally.worst.push({ K, deg, reach, mc: left }); } } return tally; } const swept = { auto: sweep(false), fixed: sweep(true) }; // ④-2 손잡이(BC · EC) 끌기 훑기(433-21 ①) — `_Pointer` 손잡이 갈래 그대로: 맞추기 전 자리에서 셈 · 하한 막힘 · // 미리보기 측점 밖이 누를 때보다 늘면 그 걸음 되돌림(`guard`) · 놓을 때 정본 맞춤. IP 셋 × 두 끝 × 12 방향 × 3 거리. function sweepHandle(fixedR, guard = true) { const start = columns(fit.fitCurveStations(columns(ROUTE), LIMITS, 1)); if (fixedR) start.curveRadius = edits .rebuildLine(copy(start), LIMITS) .nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100)); const floor = (built) => edits.curveShortfalls(built.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM); const off = (built) => fit.offStationCount(built.vertices, built.curves, INTERVAL); const tally = { drags: 0, released: 0, offAfter: 0, strayed: 0, worst: [] }; for (const K of [2, 3, 4]) for (const which of ["start", "end"]) for (let deg = 0; deg < 360; deg += 30) for (const reach of [9, 17, 26]) { const hist = copy(start); let st = { ...copy(hist), curveOn: [...hist.curveOn] }; let built = edits.rebuildLine(st, LIMITS); const offBefore = off(built); const c0 = built.curves.find((c) => c.node_first === K); if (!c0) continue; const h0 = which === "start" ? c0.start : c0.end; let raw = null; let movedOnce = false; const preview = () => { raw = st.planned.map(([x, y]) => [x, y]); st = { ...st, planned: fit.fitDrag(st, hist.planned, K, LIMITS) }; built = edits.rebuildLine(st, LIMITS); }; for (let t = 0.5; t <= reach + 1e-9; t += 0.5) { if (raw) raw.forEach((v, i) => (st.planned[i] = v)); built = edits.rebuildLine(st, LIMITS); const c = built.curves.find((entry) => entry.node_first === K); const to = [ h0[0] + Math.cos((deg * Math.PI) / 180) * t, h0[1] + Math.sin((deg * Math.PI) / 180) * t, ]; const moved = c ? curve.dragHandleTo( st.planned[K - 1], [c.apex[0], c.apex[1]], st.planned[K + 1], which, to, ) : null; if (!moved) { if (raw) preview(); continue; } const before = floor(built); const kept = [st.planned[K], st.curveRadius[K], st.curveOn[K]]; const back = () => { raw?.forEach((v, i) => (st.planned[i] = v)); [st.planned[K], st.curveRadius[K], st.curveOn[K]] = kept; built = edits.rebuildLine(st, LIMITS); preview(); }; st.planned[K] = moved.apex; st.curveRadius[K] = Math.max( edits.radiusFloorM( built.nodes[K]?.inner_angle_deg, LIMITS.limitRadiusM, LIMITS.limitArcM, ), Math.round(moved.radius * 100) / 100, ); st.curveOn[K] = true; built = edits.rebuildLine(st, LIMITS); if (edits.shortfallCrossed(before, floor(built))) { back(); continue; } preview(); if (guard && off(built) > offBefore) { back(); tally.strayed += 1; continue; } movedOnce = true; } tally.drags += 1; if (!movedOnce) continue; built = edits.rebuildLine(st, LIMITS); if (edits.floorStop(hist, st.planned, built.nodes, LIMITS)) continue; const from = fit.fitStart(hist, st); st = { ...st, planned: fit.fitCurveStations(st, LIMITS, from) }; built = edits.rebuildLine(st, LIMITS); tally.released += 1; const left = fit.offStationMcs(built.vertices, built.curves, INTERVAL); if (left.length) { tally.offAfter += 1; if (tally.worst.length < 5) tally.worst.push({ K, which, deg, reach, mc: left }); } } return tally; } const sweptHandle = { auto: sweepHandle(false), fixed: sweepHandle(true), unguarded: { auto: sweepHandle(false, false), fixed: sweepHandle(true, false) }, }; // ③ MC 측점 값 · 눈금 글 const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}'); const curveAt = Object.fromEntries( [ ["10", 10], ["station", null], ].map(([key, step]) => [key, frame.curveStations(input.grid, input.spans, 1e9, step)]), ); const labels = { int: station.stationLabel(119.8, 20), one: station.stationLabel(119.8, 20, 1), oneWhole: station.stationLabel(120, 20, 1), oneSmall: station.stationLabel(100.1, 20, 1), oneDefault: station.stationLabel(119.84, 20), }; console.log( JSON.stringify({ keys, drag, swept, sweptHandle, curve: curveAt, labels, scale: [0.1, 1, 10, null].map(frame.mcScale), }), );