Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<Vertex>) => line.map(([x, y]) => ({ x, y }));
|
||||
|
||||
/**
|
||||
* 옛 ↔ 새 누가거리 — 바뀐 구간 끝까지 그대로(측점 열쇠 = 같은 계획고) · 뒤는 길이 차(`carryProfileEdits` 의 뒤).
|
||||
* [옛 → 새, 새 → 옛].
|
||||
*/
|
||||
function carryChainage(oldLine: ReadonlyArray<Vertex>, newLine: ReadonlyArray<Vertex>) {
|
||||
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<Vertex>,
|
||||
newLine: ReadonlyArray<Vertex>,
|
||||
): 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;
|
||||
}
|
||||
|
||||
@@ -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" ? "(좌)" : "(우)";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user