diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts index ff19d2ce2..f4d8d5f2a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts @@ -3,8 +3,8 @@ * 횡단 중심점 좌우 → 노선 편집(356-3) — B06 카드가 부르는 host(`setRouteShiftHost`)를 편집기가 등록. * * 한 번 = 노드 옮김 · 끼움 한 번(고름은 `logic/B05_Profile_RouteShift`) → 편집기 한 걸음(applyEdit) → - * 계산 묶음이 뒤 측점을 조용히 다시 셈 → 새 종단이 오면 그 측점 계획고를 옮기기 전 값으로(바뀐 구간 첫 - * 계획선 맞춤에 휩쓸리지 않게 · 계획고 편집). 둘을 페이지 되돌리기 한 걸음으로(사진첩 pause · resume). + * 계산 묶음이 뒤 측점을 조용히 다시 셈 → 새 종단이 오면 옮기기 전 종단을 그대로(노면 그대로 · 364-7 · + * 바뀐 구간 첫 계획선 맞춤에 휩쓸리지 않게 · 계획고 편집). 둘을 페이지 되돌리기 한 걸음으로(사진첩 pause · resume). * ========================================================================== */ import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; @@ -15,12 +15,8 @@ import type { RouteEditHost } from "./B05_Profile_UI_RouteEdit"; import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; import type { EditedCurve } from "./logic/B05_Profile_UI_RouteEdit_Curve"; import { spliceNode } from "./logic/B05_Profile_UI_RouteEdit_Edits"; -import { - emptyEdits, - planElevationAt, - type AlignmentEdits, -} from "./logic/B05_Profile_UI_Profile_Alignment"; -import { pinStationHeight, planRouteShift } from "./logic/B05_Profile_RouteShift"; +import { emptyEdits, type AlignmentEdits } from "./logic/B05_Profile_UI_Profile_Alignment"; +import { holdGrade, planRouteShift } from "./logic/B05_Profile_RouteShift"; type Vertex = [number, number]; /** 새 종단을 기다리는 한계(ms) — 넘으면 높이 보정 없이 한 걸음을 닫음. */ @@ -72,8 +68,8 @@ export function bindRouteShift( }); if (!plan) return "노선을 아직 못 읽었습니다."; if (plan.kind === "locked") return "고정한 노드라 옮기지 않았습니다 — 고정을 먼저 푸세요."; - const profile = pipe?.output("profile"); - const zBefore = profile ? planElevationAt(profile, chainageM) : null; + const before = pipe?.output("profile"); + const lineBefore = pipe?.output("route")?.line ?? [...line]; // 끼움 · 옮김이 산 배열을 고침 const album = pageHistory(); album?.pause(); try { @@ -82,10 +78,11 @@ export function bindRouteShift( else spliceNode(now, plan.index, plan.at); const side = deltaM > 0 ? "좌" : "우"; apply(`횡단 중심점 — 측점 노선을 ${side}로 ${Math.abs(deltaM).toFixed(1)}m 옮겼습니다.`); - if (!pipe || zBefore === null || !(await routed)) return null; + if (!pipe || !before || !(await routed)) return null; const base = pipe.output("planline"); const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits(); - const pinned = base && pinStationHeight(base, edits, chainageM, zBefore); + const lineAfter = pipe.output("route")?.line ?? drawn()[0]; + const pinned = base && holdGrade(base, edits, before, lineBefore, lineAfter); if (!pinned) return null; const graded = nextProfile(pipe); pipelineGrade(projectId, pinned); diff --git a/B05_Profile/logic/B05_Profile_RouteShift.ts b/B05_Profile/logic/B05_Profile_RouteShift.ts index 1b60f8505..67932546f 100644 --- a/B05_Profile/logic/B05_Profile_RouteShift.ts +++ b/B05_Profile/logic/B05_Profile_RouteShift.ts @@ -7,18 +7,21 @@ * · P 에서 0.5 m 안 노드 = 그 노드 + Δ·L(고정 노드면 안 옮김) * · 곡선(BC ~ EC) 안 측점 = 그 교각점 + Δ·L(고정이면 안 옮김) * · 둘 다 아니면 P + Δ·L 을 그 자리 노드 토막에 끼움 - * · 높이 = 옮기기 전 계획고 − 옮긴 뒤 계획고 만큼 그 측점 계획고 편집(`adjustStation`) + * · 높이 = 노면 그대로(364-7) — 옮기기 전 종단의 꼭짓점 · R 을 계획고 편집으로 되살림(`holdGrade`) · + * 바뀐 구간 첫 계획선 맞춤(`Pipeline_Grade` planline)에 휩쓸리지 않게 · 지반만 새로 * * 브라우저가 고르는 편집값일 뿐 — 서버는 [저장] 때 노드 · 계획고 편집을 받아 정본으로 다시 셈(짝 없음). * ========================================================================== */ import { - adjustStation, buildAlignment, - planElevationAt, + chainageKey, + evaluateAt, type AlignmentBase, type AlignmentEdits, + type ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; +import { changedSpan } from "./B05_Profile_Grade_Carry"; import { curveSpans, stationFrameAt, type CurveEnds } from "./B05_Profile_UI_RouteEdit_Frame"; type Vertex = [number, number]; @@ -27,6 +30,8 @@ type Vertex = [number, number]; export const NODE_SNAP_M = 0.5; /** 이보다 작은 높이 차는 보정 안 함(m). */ export const PIN_TOLERANCE_M = 5e-4; +/** 노면 그대로 견줌 횟수. */ +const HOLD_PASSES = 4; export type RouteShiftPlan = | { kind: "move"; index: number; to: Vertex } @@ -85,13 +90,69 @@ export function planRouteShift(input: RouteShiftInput): RouteShiftPlan | null { return { kind: "insert", index: nearestSegment(planned, origin) + 1, at: shifted(origin) }; } -/** 그 측점 계획고를 `zBefore` 로 되돌린 계획고 편집 — 차가 없으면 null. */ -export function pinStationHeight( +const xy = (line: ReadonlyArray) => line.map(([x, y]) => ({ x, y })); + +/** + * 옛 ↔ 새 누가거리 — 바뀐 구간 끝까지 그대로(측점 열쇠 = 같은 계획고) · 뒤는 길이 차(`carryProfileEdits` 의 뒤). + * [옛 → 새, 새 → 옛]. + */ +function carryChainage(oldLine: ReadonlyArray, newLine: ReadonlyArray) { + const span = changedSpan(xy(oldLine), xy(newLine)); + const shift = (end: number, delta: number) => (s: number) => (span && s >= end ? s + delta : s); + return [ + shift(span?.old_end_m ?? 0, span?.delta_m ?? 0), + shift(span?.new_end_m ?? 0, -(span?.delta_m ?? 0)), + ]; +} + +/** + * 노면 그대로(364-7) — 노선을 옮긴 뒤 새 기준(`base` · 바뀐 구간 첫 계획선) 위에 옮기기 전 종단(`before`)의 + * 꼭짓점 · 종단곡선 R 을 계획고 편집으로 되살림. 두 꺾은선의 꼭짓점을 모두 맞추면 두 선이 같음 — + * 다른 꼭짓점 · 새 기준에 없는 옛 꼭짓점만 고정(바뀐 구간 밖은 이미 같아 편집 안 늚). 차가 없으면 null. + * ponytail: 새 기준에만 있는 꼭짓점(옛 선 위로 고정)이 옛 종단곡선 반쪽 한계를 줄일 수 있음 — 측점마다 + * 꼭짓점인 기준(지금 첫 계획선)이면 옛 선과 꼭짓점이 같아 없음. + */ +export function holdGrade( base: AlignmentBase, edits: AlignmentEdits, - chainageM: number, - zBefore: number, + before: ProfileAlignment, + oldLine: ReadonlyArray, + newLine: ReadonlyArray, ): AlignmentEdits | null { - const drift = zBefore - planElevationAt(buildAlignment(base, edits), chainageM); - return Math.abs(drift) < PIN_TOLERANCE_M ? null : adjustStation(base, edits, chainageM, drift); + const [carry, back] = carryChainage(oldLine, newLine); + const oldS = before.pvi.map((node) => node.chainage_m); + const oldZ = before.pvi.map((node) => node.elevation_m); + const oldKeys = new Set(oldS.map((s) => chainageKey(carry(s)))); + const stationS = base.stations.map((station) => station.chainage_m); + const baseS = base.basePvi.map((node) => node.chainage_m); + const baseZ = base.basePvi.map((node) => node.elevation_m); + const line = (s: number[], z: number[], at: number): number => evaluateAt(s, z, [], at); + const offsets = { ...edits.station_offsets }; + const radii = { ...edits.curve_radii }; + let changed = false; + // 고정한 꼭짓점이 이웃 측점 사이 선을 바꿈 — 다시 견줘 남은 측점까지 + for (let pass = 0; pass < HOLD_PASSES; pass += 1) { + const now = buildAlignment(base, { ...edits, station_offsets: offsets }).pvi; + const nowS = now.map((node) => node.chainage_m); + const nowZ = now.map((node) => node.elevation_m); + const nowKeys = new Set(nowS.map(chainageKey)); + let pinned = false; + for (const key of new Set([...oldS.map(carry), ...nowS, ...stationS].map(chainageKey))) { + const at = Number(key); + const z = line(oldS, oldZ, back(at)); + const lost = oldKeys.has(key) && !nowKeys.has(key); // 옛 꺾임이 새 선에 없음 + if (!lost && Math.abs(line(nowS, nowZ, at) - z) < PIN_TOLERANCE_M) continue; + offsets[key] = z - line(baseS, baseZ, at); // 반올림 안 함 — 옛 선 위 꼭짓점이 꺾임 안 만듦 + pinned = true; + } + if (!pinned) break; + changed = true; + } + Object.entries(before.edits.curve_radii).forEach(([key, radius]) => { + const at = chainageKey(carry(Number(key))); + if (radii[at] !== undefined) return; + radii[at] = radius; + changed = true; + }); + return changed ? { ...edits, station_offsets: offsets, curve_radii: radii } : null; } diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 217f576d1..355e83579 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -488,22 +488,17 @@ export function attachDesignLinePick(input: { if (axis === "center") return centerShift?.step(dir); if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx)); }; + // 툴팁 = 고른 것의 실제 동작(364-7 · 사면 = 노견째 이동 · 중심점 = 그 측점 노선) + const stepTitle = (screen: "왼쪽" | "오른쪽"): string => + axis === "center" + ? `이 측점 노선을 화면 ${screen}으로 0.1 m` + : `고른 사면을 화면 ${screen}으로 0.1 m(노견째 · 노폭 안쪽은 못 감)`; move.controls.append( - dpadButton( - "◀", - "left", - "고른 사면을 화면 왼쪽으로 0.1 m(노견째 · 노폭 안쪽은 못 감)", - step(-1), - ), + dpadButton("◀", "left", stepTitle("왼쪽"), step(-1)), dpadButton("↺", "reset", "이 쪽 이동 0", () => { if (side && shift) shift.set(side, 0); }), - dpadButton( - "▶", - "right", - "고른 사면을 화면 오른쪽으로 0.1 m(노견째 · 노폭 안쪽은 못 감)", - step(1), - ), + dpadButton("▶", "right", stepTitle("오른쪽"), step(1)), ); // 소단 높이 줄(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤) const lift = makeRow("높이"); @@ -574,6 +569,8 @@ export function attachDesignLinePick(input: { move.row.classList.toggle("is-hidden", !sideways); move.controls.classList.toggle("is-hidden", !sideways); move.controls.children[1]?.classList.toggle("is-hidden", axis === "center"); + const [toLeft, , toRight] = move.controls.children as unknown as HTMLElement[]; + [toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")]; lift.row.classList.toggle("is-hidden", axis !== "depth"); lift.controls.classList.toggle("is-hidden", axis !== "depth"); const sideName = side === "left" ? "(좌)" : "(우)"; diff --git a/resources/tester/helper_356_4_undo_grade.cjs b/resources/tester/helper_356_4_undo_grade.cjs index c72761132..c687bfe6d 100644 --- a/resources/tester/helper_356_4_undo_grade.cjs +++ b/resources/tester/helper_356_4_undo_grade.cjs @@ -407,15 +407,18 @@ async function run() { // 중심점 좌우 꼴 — 끌기 + C 계획고를 끌기 전 값으로(한 걸음) const zBefore = state().z; await step(async () => { + const before = pipe().output("profile"); + const lineBefore = pipe().output("route").line; bridge.edited(draftOf(shift(1, 12, -8))); await quiet(); const p = pipe(); - const pinned = shiftMod.pinStationHeight( + const pinned = shiftMod.holdGrade( p.output("planline"), p.input("alignment"), - C, - zBefore, - ); + before, + lineBefore, + p.output("route").line, + ); // 364-7 노면 그대로 if (pinned) await grade(pinned); }); out.pinned = { ...state(), zBefore }; diff --git a/resources/tester/helper_356_center.cjs b/resources/tester/helper_356_center.cjs index 4c36e2ef2..2e20a658d 100644 --- a/resources/tester/helper_356_center.cjs +++ b/resources/tester/helper_356_center.cjs @@ -107,15 +107,17 @@ const policy = { curve_skip_delta_pct: 0, paved: true, }; -const stations = [0, 20, 40, 60, 80, 100]; -const baseOf = (pvi) => ({ +const stationsTo = (end) => [0, 20, 40, 60, 80, end]; // 끝 측점 = 노선 길이 +const baseOf = (pvi, end = 100) => ({ policy, onlyExplicitCurves: true, basePvi: pvi.map(([chainage_m, elevation_m]) => ({ chainage_m, elevation_m })), - chainage: stations, - ground: stations.map(() => 100), - stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })), + chainage: stationsTo(end), + ground: stationsTo(end).map(() => 100), + stations: stationsTo(end).map((s) => ({ station_id: `S${s}`, chainage_m: s })), }); +const lengthOf = (line) => + line.slice(1).reduce((sum, [x, y], i) => sum + Math.hypot(x - line[i][0], y - line[i][1]), 0); const savedBase = baseOf([ [0, 100], [100, 110], @@ -128,7 +130,15 @@ const refitBase = baseOf([ ]); let generation = 0; const listeners = new Set(); -const state = { base: savedBase, edits: align.emptyEdits() }; +// 바뀐 구간 안 사용자 계획고 · R(364-7) — 묶음이 버려도 노면 그대로 되살아나야 함 +const state = { + line: straight.map((p) => [...p]), + base: savedBase, + edits: { station_offsets: { "20.000": 1 }, curve_radii: { "20.000": 200 } }, +}; +const heights = () => pipe.output("profile").stations.map((row) => row.plan_elevation_m); +const maxGrade = () => + Math.max(...pipe.output("profile").segments.map((g) => Math.abs(g.grade_percent))); const emit = () => { const gen = generation; setTimeout(() => { @@ -145,10 +155,17 @@ pipe = { ? state.base : name === "profile" ? align.buildAlignment(state.base, state.edits) - : null, + : name === "route" + ? { line: state.line } + : null, input: (key) => (key === "alignment" ? state.edits : null), route(planned) { - state.base = refitBase; + const end = lengthOf(planned); + state.line = planned.map((p) => [...p]); + state.base = baseOf( + [...refitBase.basePvi.slice(0, -1).map((n) => [n.chainage_m, n.elevation_m]), [end, 110]], + end, + ); state.edits = align.emptyEdits(); // 바뀐 구간 안 편집은 버림 sessionStorage.setItem("aislo:draft:route-draft:p", JSON.stringify(planned)); sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(state.edits)); @@ -192,6 +209,9 @@ const mountEditor = () => (async () => { const zBefore = align.planElevationAt(pipe.output("profile"), 40); + out.heightsBefore = heights(); + out.gradeBefore = maxGrade(); + out.curvesBefore = pipe.output("profile").curves.map((c) => [c.chainage_m, c.r_m]); const sessionBefore = JSON.stringify([...session.entries()]); // B06 바로 열기 — host 없음 → 페이지가 단 길(종단 보기 숨긴 채 세움)이 편집기를 세움 let opened = 0; @@ -209,6 +229,9 @@ const mountEditor = () => out.graded = graded.length; out.zBefore = zBefore; out.zAfter = align.planElevationAt(pipe.output("profile"), 40); + out.heightsAfter = heights(); + out.gradeAfter = maxGrade(); + out.curvesAfter = pipe.output("profile").curves.map((c) => [c.chainage_m, c.r_m]); out.zRefit = align.planElevationAt(align.buildAlignment(refitBase, align.emptyEdits()), 40); out.canUndo = album.canUndo(); album.undo(); diff --git a/resources/tester/test_356_b06_card_center.py b/resources/tester/test_356_b06_card_center.py index 9455940de..e3150e9fa 100644 --- a/resources/tester/test_356_b06_card_center.py +++ b/resources/tester/test_356_b06_card_center.py @@ -4,7 +4,8 @@ - 356-2 카드 초점 없음 — 마우스로 고른 뒤 키를 눌러도 초점 테두리(다른 색) 안 뜸 · ▲▼ 계획고는 창 듣개. - 356-3 중심점 = 보라 선 고름 끝 조각(판정 원 · 노면 판정보다 위 · 다른 고름과 하나만) · ▲▼ = 종단 계획고 · ◀▶ · 가로 끌기 = 그 측점 노선 좌우(0.5 m 안 노드 옮김 · 곡선 안 = 교각점 · 아니면 끼움 · 고정 노드 안 옮김) · - 놓을 때 한 번 · 뒤 측점은 묶음이 다시 셈(바뀐 범위 = 앞 노드 ~ 끝) · 그 측점 계획고 그대로 · 되돌리기 한 걸음 · + 놓을 때 한 번 · 뒤 측점은 묶음이 다시 셈(바뀐 범위 = 앞 노드 ~ 끝) · 노면 그대로(364-7 · 모든 측점 계획고 · + 기울기 · R 그대로) · 되돌리기 한 걸음 · B06 을 바로 열어 편집기가 없으면 종단 보기를 숨긴 채 세움. 진짜 TS 는 `helper_356_center.cjs` 가 Node 로 돌림. @@ -67,6 +68,10 @@ def test_높이_그대로_되돌리기_한_걸음_바로_열기(ts): assert len(ts["applied"]) == 1 # 편집기 한 걸음 assert ts["zRefit"] != pytest.approx(ts["zBefore"], abs=1e-3) # 묶음 다시 맞춤이면 쓸림 assert ts["graded"] == 1 and ts["zAfter"] == pytest.approx(ts["zBefore"], abs=1e-3) + # 364-7 노면 그대로 — 바뀐 구간 모든 측점 계획고 · 최대 기울기 · 종단곡선 R 그대로 + assert ts["heightsAfter"] == pytest.approx(ts["heightsBefore"], abs=1e-3) + assert ts["gradeAfter"] == pytest.approx(ts["gradeBefore"], abs=1e-6) + assert sum(ts["curvesAfter"], []) == pytest.approx(sum(ts["curvesBefore"], [])) assert ts["canUndo"] and ts["undoOne"] and not ts["canUndoAfter"] # 노선 + 계획고 = 한 걸음 locked = ts["locked"] assert locked["moved"] is False and locked["toast"][1] == "info" @@ -77,6 +82,11 @@ def test_배선(): pick = (B06 / "B06_Section_UI_Cross_Design_Pick.ts").read_text(encoding="utf-8") assert "center: section.chainage_m," in pick # designLineMoves → 카드 고름 끝 조각 assert 'if (axis === "center") return centerShift?.step(dir);' in pick + # 364-7 덧 — 중심점 ◀▶ 툴팁 = 노선 좌우 · ↺ · 소단 ▲▼ 숨김 + assert "`이 측점 노선을 화면 ${screen}으로 0.1 m`" in pick + assert '[toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")];' in pick + assert 'move.controls.children[1]?.classList.toggle("is-hidden", axis === "center");' in pick + assert 'lift.row.classList.toggle("is-hidden", axis !== "depth");' in pick order = (B06 / "B06_Section_UI_Cross_Pick_Order.ts").read_text(encoding="utf-8") assert "layer.append(...surfaces, ...edges, ...lines, ...centers);" in order edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")