diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 3103c59f7..04d89ca2d 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -27,7 +27,7 @@ import { createDesignPipeline, publishToStore, type DesignPipeline } from "./log import { SOURCE_INPUT } from "./logic/design_pipeline_types"; export { pipelineWork } from "./logic/design_pipeline"; import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross"; -import { createStationsStage, stationDiff } from "./logic/design_pipeline_stations"; +import { createStationsStage, snapOptions, stationDiff } from "./logic/design_pipeline_stations"; import type { ChangeSource, FollowPatch, @@ -298,12 +298,7 @@ function createPagePipeline( route.curves, (patch.pipes ?? inputs.pipes ?? []) as PlanPipe[], (patch.structures ?? inputs.structures ?? []) as PlanStructure[], - { - intervalM: options.stationIntervalM, - types: enabled, - stationSnap: inputs.route?.stationSnap ?? false, - stationSnapStep: inputs.route?.stationSnapStep, - }, + { intervalM: options.stationIntervalM, types: enabled, ...snapOptions(inputs.route, plan) }, ).map((station) => station.chainageM), }); /** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */ @@ -342,7 +337,11 @@ function createPagePipeline( }; return followStage({ ...ctx, inputs }); }, - stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => enabled }), + stations: createStationsStage({ + intervalM: options.stationIntervalM, + types: () => enabled, + saved: () => plan, + }), ground: createGroundStage(groundDeps), planline: createPlanlineStage({ savedBase: (at) => options.plantedBase?.(savedSource(), at, types) ?? savedSource().base, diff --git a/A00_Common/design/logic/design_pipeline_stations.ts b/A00_Common/design/logic/design_pipeline_stations.ts index c2df06dc0..9c4d19d62 100644 --- a/A00_Common/design/logic/design_pipeline_stations.ts +++ b/A00_Common/design/logic/design_pipeline_stations.ts @@ -17,6 +17,7 @@ import type { import type { PipelineRunRecord, PipelineStation, + RouteDraft, StageContext, StageFn, } from "./design_pipeline_types"; @@ -41,12 +42,30 @@ export function reuseOutside( }); } -/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 노선 초안 스냅. */ +/** 저장 노선의 측점 스냅 설정(`RoutePlanResponse` 칸). */ +export interface SavedSnap { + station_snap?: boolean; + station_snap_step?: number | null; +} + +/** 측점 스냅 설정 — 노선 초안이 있으면 그 값 · 없으면 저장 노선 값(441 · 저장 스냅 노선 측점 어긋남). */ +export function snapOptions( + draft: RouteDraft | null | undefined, + saved: SavedSnap | null | undefined, +): Pick { + return draft + ? { stationSnap: draft.stationSnap, stationSnapStep: draft.stationSnapStep } + : { stationSnap: saved?.station_snap ?? false, stationSnapStep: saved?.station_snap_step }; +} + +/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */ export function createStationsStage( options: Omit & { types: () => ReadonlyArray; + saved?: () => SavedSnap | null; }, ): StageFn<"stations"> { + const { saved, ...rest } = options; return (ctx: StageContext<"stations">) => { const route = ctx.out.route; if (!route) return []; @@ -55,12 +74,7 @@ export function createStationsStage( route.curves, (ctx.inputs.pipes as PlanPipe[] | null) ?? [], (ctx.inputs.structures as PlanStructure[] | null) ?? [], - { - ...options, - types: options.types(), - stationSnap: ctx.inputs.route?.stationSnap ?? false, - stationSnapStep: ctx.inputs.route?.stationSnapStep, - }, + { ...rest, types: options.types(), ...snapOptions(ctx.inputs.route, saved?.()) }, ); return reuseOutside(next, ctx.prev, ctx.range); }; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 85adc5795..3fc47eccc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -544,6 +544,7 @@ export async function mountRouteEditor( if (base) markEdited(); return base !== undefined; }, + offStation: () => (stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0), }); bindNodeSplice({ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts index d57b14104..c7db6c03d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Keys.ts @@ -31,6 +31,8 @@ interface Hold { start: Vertex; /** 스냅 미리보기 전 노선(맞추기 전) — 다음 걸음 · 하한 검사는 여기서(맞춤이 쌓이지 않음). */ raw: Vertex[] | null; + /** 누를 때 측점 밖 곡선 수(441) — 미리보기가 이보다 늘면 그 걸음 되돌림. */ + off: number; moved: boolean; } @@ -74,8 +76,18 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () = params.draw(); return; } - now.moved = true; fitPreview(now, state.planned); + if (now.raw && params.offStation() > now.off) { + // 맞춰도 MC 가 측점 밖에 남는 자리 — 끌기와 같게 그 걸음만 되돌림(441) + const planned = params.state().planned; + now.raw.forEach((vertex, index) => (planned[index] = vertex)); + planned[now.index] = node; + fitPreview(now, planned); + status.textContent = `${params.routeHead()} — 곡선 가운데를 측점에 못 맞춰 더 못 옮깁니다.`; + params.draw(); + return; + } + now.moved = true; status.textContent = `${params.routeHead()} — 방향키로 옮기는 중(뗄 때 한 걸음). ${params.curveHint()}`; params.draw(); } @@ -123,6 +135,7 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () = before: state.planned.map(([x, y]): Vertex => [x, y]), start: [node[0], node[1]], raw: null, + off: params.offStation(), moved: false, }; hold = now; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 45f975742..73d635759 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -106,6 +106,8 @@ export interface RouteEditPointerParams { /** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에 · * 맞췄으면 true(끔이면 false · 아무 일 없음). */ fitDrag: (index: number) => boolean; + /** 측점 밖 곡선 수(스냅 끔 = 0 · 441 끌기 · 방향키 막힘). */ + offStation: () => number; } export interface RouteEditPointer { @@ -191,8 +193,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP /** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다 * (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */ let dragMoved = false; - /** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */ + /** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격 밂). */ let dragRaw: Vertex[] | null = null; + /** 누를 때 측점 밖 곡선 수(441) — 미리보기가 이보다 늘면 그 걸음 되돌림(못 맞추는 자리로 못 끎). */ + let dragOff = 0; /** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */ const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); @@ -227,6 +231,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP // 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①). dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1; dragRaw = null; + dragOff = params.offStation(); dragHandle = hitNode >= 0 ? null : handleAt(px, py); dragMoved = false; if (hitNode >= 0) { @@ -353,7 +358,6 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP // **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④). const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM); const previous = state.planned[dragNode]; - dragMoved = true; state.planned[dragNode] = toMetric(px, py); params.markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. const after = params.state(); @@ -372,6 +376,17 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP return; } fitPreview(state.planned); // 스냅 켬 = 끄는 동안 MC 를 간격 배수에(390-5 · 놓을 때 정본) + if (dragRaw && params.offStation() > dragOff) { + // 맞춰도 MC 가 측점 밖에 남는 자리 — 하한 막힘과 같게 그 걸음만 되돌림(441) + const now = params.state().planned; + dragRaw.forEach((vertex, index) => (now[index] = vertex)); + now[dragNode] = previous; + fitPreview(now); + status.textContent = `${params.routeHead()} — 곡선 가운데를 측점에 못 맞춰 더 못 옮깁니다.`; + params.draw(); + return; + } + dragMoved = true; // 막힘 검사 뒤 — 막힌 채 놓으면 되돌릴 걸음 없음(433-14) // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`; diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index 8a9fbd34c..f54583bba 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -3,7 +3,7 @@ * 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에(366-3 · 요구 240-11) — 손 편집 평면. * * 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴: - * · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격/2 안에서 + * · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격 안에서 * 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠. * · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로. * · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음. @@ -113,7 +113,7 @@ function solve( if (!(length > 1e-9)) return null; const ux = (base[0] - before[0]) / length; const uy = (base[1] - before[1]) / length; - const reach = limits.intervalM / 2; + const reach = limits.intervalM; // 한가운데(18+9 ~ 19+1)도 가까운 측점까지(441 · 반 간격이면 못 닿음) const clamp = (d: number): number => Math.max(-reach, Math.min(reach, d)); const at = (d: number) => { const next = planned.slice(); diff --git a/resources/tester/helper_266_card_toggle.mjs b/resources/tester/helper_266_card_toggle.mjs index ffb22fe62..33f07986e 100644 --- a/resources/tester/helper_266_card_toggle.mjs +++ b/resources/tester/helper_266_card_toggle.mjs @@ -95,15 +95,17 @@ async function toggles(projectId, shift) { const steps = []; const run = async (tag) => { const updated = []; + let calls = 0; // 받는 쪽 = 카드 다시 그림 + 종단 · 유토 다시 그림(design_shared) — 한 걸음에 한 번 const out = await m.refreshSavedCross( { projectId, routeId: R, detail, edits: EDITS, alignment }, stations, - (rows) => updated.push(...rows), + (rows) => (calls++, updated.push(...rows)), ); steps.push({ tag, out: out ? Object.keys(out.byKey).length : null, updated, + calls, ...pick(target.design), }); }; @@ -126,6 +128,9 @@ async function toggles(projectId, shift) { m.writeCrossDesignChoice(projectId, R, target.chainage_m, { ...choice }); await run(tag); } + // 사면 이동 한 걸음(B06 ◀▶ 0.1 m · 깎기 쪽 · 441 · 요구 268-4) + m.writeShoulderExtend(projectId, R, target.chainage_m, job.mode.split("_")[0], 0.1); + await run("slope"); return { target: target.chainage_m, steps }; } diff --git a/resources/tester/helper_266_card_toggle_entry.ts b/resources/tester/helper_266_card_toggle_entry.ts index 4ec8245d0..bc2b48863 100644 --- a/resources/tester/helper_266_card_toggle_entry.ts +++ b/resources/tester/helper_266_card_toggle_entry.ts @@ -1,5 +1,8 @@ /* PLAN 439 시험 묶음 진입점 — 저장 노선 cross 차례(카드 단추 → 다시 셈 · 측점 어긋남) · 진짜 모듈 · vite ssr. */ export { refreshSavedCross } from "../../A00_Common/design/design_shared"; -export { writeCrossDesignChoice } from "../../B06_Section/B06_Section_Cross_Design_Session"; +export { + writeCrossDesignChoice, + writeShoulderExtend, +} from "../../B06_Section/B06_Section_Cross_Design_Session"; export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel"; export { writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/helper_344_3_pipeline_stations.cjs b/resources/tester/helper_344_3_pipeline_stations.cjs index 45d4a74a5..b7508c923 100644 --- a/resources/tester/helper_344_3_pipeline_stations.cjs +++ b/resources/tester/helper_344_3_pipeline_stations.cjs @@ -78,4 +78,24 @@ out.move = { same: after.map((s) => byKey.get(s.key) === s), empty: run(null, null, null, null), }; +// 441 — 초안 없는 저장 노선 = 저장 스냅 설정 따름 · 초안이 있으면 초안 값(스냅 case = cases[1]) +const snapCase = job.cases[1]; +const savedRun = (route, saved) => + stage + .createStationsStage({ types: () => job.types, saved: () => saved })({ + generation: 1, + range: null, + signal: new AbortController().signal, + inputs: { route, pipes: job.pipes, structures: job.structures }, + out: { route: { line: snapCase.line, curves: snapCase.curves, chainage: [], lengthM: 0 } }, + before: {}, + prev: null, + }) + .map((s) => s.chainageM); +out.savedSnap = { + saved: savedRun(null, { station_snap: true, station_snap_step: null }), + savedOff: savedRun(null, { station_snap: false }), + noPlan: savedRun(null, null), + draftWins: savedRun({ stationSnap: false }, { station_snap: true }), +}; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_390_5_snap_step_ops.cjs b/resources/tester/helper_390_5_snap_step_ops.cjs index bc613af8c..da8e57ca5 100644 --- a/resources/tester/helper_390_5_snap_step_ops.cjs +++ b/resources/tester/helper_390_5_snap_step_ops.cjs @@ -128,6 +128,82 @@ const drag = Object.fromEntries( }), ); +// ④ 끌기 훑기(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) }; + // ③ MC 측점 값 · 눈금 글 const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}'); const snapped = Object.fromEntries( @@ -147,5 +223,12 @@ const labels = { }; console.log( - JSON.stringify({ keys, drag, snapped, labels, scale: [0.1, 1, 10, null].map(frame.mcScale) }), + JSON.stringify({ + keys, + drag, + swept, + snapped, + labels, + scale: [0.1, 1, 10, null].map(frame.mcScale), + }), ); diff --git a/resources/tester/test_266_card_toggle_mismatch.py b/resources/tester/test_266_card_toggle_mismatch.py index 6d2d73835..30e03293d 100644 --- a/resources/tester/test_266_card_toggle_mismatch.py +++ b/resources/tester/test_266_card_toggle_mismatch.py @@ -4,6 +4,7 @@ - 저장 노선 cross 차례(`refreshSavedCross`) = 카드 자료(상세 설계)를 **늘** 다시 셈 — 복합경사 · 측구 · 지반 단추마다 그 측점 설계선 · 면적이 바뀜. - 묶음 측점 = 상세 측점이면 돌려줌 · 1 mm 넘게 다르면 null(묶음 엔진) — 그래도 카드 자료는 고침. + - 사면 이동 한 걸음도 같음 · 걸음마다 받는 쪽(카드 · 종단 · 유토 다시 그림) 한 번(441 · 요구 268-2 · 4). - 서버 폴백(선형 저장분 없음)에도 카드 선택이 실림(열쇠 = 측점 자리) · 서버는 저장분 위에 얹음. 짝: `helper_266_card_toggle.mjs` · 진입점 `helper_266_card_toggle_entry.ts` · 지형 · 노선 틀 = `test_346_2_pipeline_cross_page.py`. @@ -99,7 +100,8 @@ def _check_toggles(case: dict, returned) -> None: steps = {step["tag"]: step for step in case["steps"]} assert steps["open"]["cut"] > 0.5 # 깎기 측점 before = steps["open"] - for tag in ("two_stage", "ditch_off", "soil"): + assert all(step["calls"] == 1 for step in case["steps"]) # 걸음마다 다시 그림 한 번(441) + for tag in ("two_stage", "ditch_off", "soil", "slope"): step = steps[tag] assert case["target"] in step["updated"], tag # 카드 다시 그림 대상 assert step["line"] != before["line"], tag # 카드 설계선 바뀜 diff --git a/resources/tester/test_344_3_pipeline_stations_mirror.py b/resources/tester/test_344_3_pipeline_stations_mirror.py index 196fcfe58..3c634451d 100644 --- a/resources/tester/test_344_3_pipeline_stations_mirror.py +++ b/resources/tester/test_344_3_pipeline_stations_mirror.py @@ -264,6 +264,17 @@ def test_스냅_측점은_원래_격자_번호(r): assert moved and all(abs(a - b) <= 10 for a, b in moved.values()) +def test_초안_없으면_저장_스냅_따름(r): + """441 — 초안 없는 저장 노선 측점 단계 = 저장 station_snap · 초안이 있으면 초안 값.""" + snapped = [s["chainageM"] for s in r["cases"][1]] + plain = [s["chainageM"] for s in r["cases"][0]] + assert snapped != plain + got = r["savedSnap"] + assert got["saved"] == snapped + assert got["savedOff"] == plain and got["noPlan"] == plain + assert got["draftWins"] == plain + + def test_노선_한_점_옮김_앞_구간_열쇠_그대로(r): m = r["move"] from_m = _first_changed_m() diff --git a/resources/tester/test_390_5_snap_step_ops.py b/resources/tester/test_390_5_snap_step_ops.py index 3b7c3e9fe..e1813cbbe 100644 --- a/resources/tester/test_390_5_snap_step_ops.py +++ b/resources/tester/test_390_5_snap_step_ops.py @@ -80,7 +80,17 @@ def test_끄는_동안_MC_가_간격_배수(got, step) -> None: drag = got["drag"][step] assert drag["off"] == 0 assert max(drag["residues"]) <= drag["tol"] - assert drag["dragMoved"] <= float(step) / 2 + 1e-6 # 끈 IP 는 접선 따라 ±간격/2 안만 + assert drag["dragMoved"] <= float(step) + 1e-6 # 끈 IP 는 접선 따라 ±간격 안만(441) + + +@pytest.mark.parametrize("mode", ["auto", "fixed"]) +def test_끌기_훑기_놓은_뒤_측점_밖_0(got, mode) -> None: + """441 — 측점 No. 끌기(IP 셋 × 12 방향 × 3 거리) · 놓은 뒤 MC 가 다 측점 위 · 맞춤이 늘 닿음 + (반 간격 한도면 18+9 ~ 19+1 에서 못 맞춰 막힘 걸음이 생김).""" + swept = got["swept"][mode] + assert swept["drags"] == 108 and swept["released"] > 0 + assert swept["offAfter"] == 0, swept["worst"] + assert swept["strayed"] == 0 @pytest.mark.parametrize("step", ["0.1", "1", "10", "20"]) @@ -101,6 +111,12 @@ def test_끌기_방향키_편집기_이음() -> None: # 하한 검사는 맞추기 전 노선으로(맞춤이 IP 를 ±간격/2 밀어 10 m · 측점 No. 에서 헛걸림) assert "dragRaw.forEach((vertex, index) => (state.planned[index] = vertex))" in pointer assert "now.raw.forEach((vertex, index) => (state.planned[index] = vertex))" in keys + # 미리보기 측점 밖이 누를 때보다 늘면 그 걸음 되돌림 · 막힘 검사 뒤에 옮김 표시(441 · 433-14) + assert "params.offStation() > dragOff" in pointer and "params.offStation() > now.off" in keys + move = pointer[pointer.index("const previous = state.planned[dragNode];") :] + assert move.index("shortfallCrossed(") < move.index("params.offStation() > dragOff") + assert move.index("params.offStation() > dragOff") < move.index("dragMoved = true;") + assert "offStation: () => (stationSnap ? fit.offStationCount(" in edit assert edit.count("\n") <= 695 @@ -193,6 +209,9 @@ def test_서버_측점_셈이_저장_간격을_씀() -> None: page = (design / "design_pipeline_page.ts").read_text(encoding="utf-8") assert "stationSnapStep: routePlan.station_snap_step" in page stage = (design / "logic" / "design_pipeline_stations.ts").read_text(encoding="utf-8") - assert "stationSnapStep: ctx.inputs.route?.stationSnapStep" in stage + assert "...snapOptions(ctx.inputs.route, saved?.())" in stage # 초안 · 없으면 저장 노선(441) + assert "stationSnapStep: draft.stationSnapStep" in stage + assert "stationSnapStep: saved?.station_snap_step" in stage + assert "saved: () => plan" in page and "...snapOptions(inputs.route, plan)" in page apply = (B05 / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8") assert "stationSnap: true, stationSnapStep: null" in apply # 추천 = 측점 No.