Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_6' into sub_laptop_1
This commit is contained in:
@@ -152,8 +152,8 @@ export interface FollowPatch {
|
||||
alignment?: AlignmentEdits;
|
||||
}
|
||||
|
||||
/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점). */
|
||||
export type StationKind = "grid" | "structure" | "pipe" | "end";
|
||||
/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점) · 곡선(MC · 252-2 · 격자 번호 없음). */
|
||||
export type StationKind = "grid" | "structure" | "pipe" | "end" | "curve";
|
||||
|
||||
/** 횡단 지반 표본 한 점 — 중심에서 거리(m · 좌 +) · 높이(null = 빈칸). */
|
||||
export interface GroundSample {
|
||||
|
||||
@@ -214,6 +214,8 @@ export interface StationTickOptions {
|
||||
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2).
|
||||
* 주면 라벨은 그 값 자릿수로(소수 한 자리까지 · 스냅 간격 0.1 의 MC · 390-5) · 안 주면 정수. */
|
||||
chainages?: ReadonlyArray<number>;
|
||||
/** 곡선 측점(MC · B05 요구 252-2) 누가거리 — 그 눈금 이름 뒤 「 (MC)」 · 늘 그림(솎지 않음). */
|
||||
mcChainages?: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
/** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */
|
||||
@@ -260,8 +262,9 @@ export function drawStationTicks(
|
||||
if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total));
|
||||
else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage);
|
||||
for (const chainage of marks) {
|
||||
const mc = options.mcChainages?.includes(chainage) ?? false;
|
||||
const stationNo = Math.round(chainage / interval);
|
||||
if (stationNo % step !== 0) continue;
|
||||
if (stationNo % step !== 0 && !mc) continue;
|
||||
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
||||
const back = points[cursor - 1];
|
||||
const front = points[cursor];
|
||||
@@ -288,7 +291,7 @@ export function drawStationTicks(
|
||||
context.strokeStyle = "rgba(40, 40, 40, 0.85)";
|
||||
context.stroke();
|
||||
|
||||
const label = stationLabel(chainage, interval, options.chainages ? 1 : 0);
|
||||
const label = stationLabel(chainage, interval, options.chainages ? 1 : 0) + (mc ? " (MC)" : "");
|
||||
const width = context.measureText(label).width + 6;
|
||||
const half = width / 2;
|
||||
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
`snapCurveStations` · `mcScale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
|
||||
간격별 MC 값 `test_390_5_snap_step_ops.py`.
|
||||
⚠ 파이썬·TS 짝 — `generate_sections` 지반 부분 ↔ `B05_Profile/logic/B05_Profile_Sections_Ground.ts`.
|
||||
⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 측점 병합 · 종류 · 방위각) ·
|
||||
⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 · 곡선 측점 · 종류 · 방위각) ·
|
||||
`_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `B05_Profile/logic/B05_Profile_Sections_Stations.ts`
|
||||
(`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`.
|
||||
⚠ 파이썬·TS 짝 — `generate_sections` 상단측(`uphill_side`) ↔ `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts`
|
||||
@@ -324,6 +324,27 @@ def snap_curve_stations(
|
||||
return np.unique(np.asarray(values, dtype=np.float64))
|
||||
|
||||
|
||||
def curve_stations(
|
||||
stations: np.ndarray | list[float],
|
||||
spans: list[tuple[float, ...]],
|
||||
total: float,
|
||||
step_m: float | None = None,
|
||||
) -> list[float]:
|
||||
"""곡선 측점(요구 252-2) — MC(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)에 측점이
|
||||
없으면 그 값 · 0.005 m 안 측점(일반 · 스냅으로 옮긴 · 관 · 구조물)이 있거나 노선 밖이면 뺌 ·
|
||||
BC · EC 는 안 더함. `curveStations` 짝."""
|
||||
scale = _mc_scale(step_m)
|
||||
taken = [float(value) for value in stations]
|
||||
out: list[float] = []
|
||||
for span in spans:
|
||||
value = math.floor((float(span[0]) + float(span[1])) / 2.0 * scale + 0.5) / scale
|
||||
if not 0.0 < value < total or any(abs(value - other) < 0.005 for other in taken):
|
||||
continue
|
||||
taken.append(value)
|
||||
out.append(value)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _design_curve_widenings(
|
||||
station_chainage: np.ndarray,
|
||||
spans: list[tuple[float, float, float, str]],
|
||||
@@ -375,6 +396,7 @@ def generate_sections(
|
||||
|
||||
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) ·
|
||||
`station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.).
|
||||
MC 에 측점이 없으면 곡선 측점(kind `curve` · `curve_stations` · 요구 252-2) 하나를 더함.
|
||||
"""
|
||||
options = options or SectionGenerationOptions()
|
||||
options.validate()
|
||||
@@ -413,6 +435,11 @@ def generate_sections(
|
||||
station_chainage = np.unique(
|
||||
np.r_[station_chainage, np.array(sorted(extras), dtype=np.float64)]
|
||||
)
|
||||
# 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m)
|
||||
step = station_snap_step if station_snap else None
|
||||
curves = {round(value, 6) for value in curve_stations(station_chainage, spans, total, step)}
|
||||
if curves:
|
||||
station_chainage = np.unique(np.r_[station_chainage, np.array(sorted(curves))])
|
||||
station_xy = _interpolate_xy(points, route_chainage, station_chainage)
|
||||
tangents = np.vstack(
|
||||
[
|
||||
@@ -477,6 +504,8 @@ def generate_sections(
|
||||
# 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림
|
||||
# (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`).
|
||||
kind = "irregular"
|
||||
elif rounded in curves:
|
||||
kind = "curve" # 곡선 측점(252-2) — 격자 번호 없음 · 열쇠 `curve:<누가거리>`
|
||||
else:
|
||||
kind = "regular"
|
||||
center_index = int(np.argmin(np.abs(offsets)))
|
||||
@@ -509,7 +538,7 @@ def generate_sections(
|
||||
station = {
|
||||
"station_id": station_id,
|
||||
"chainage_m": round(float(value), 6),
|
||||
"label": format_station(float(value)),
|
||||
"label": format_station(float(value)) + (" (MC)" if kind == "curve" else ""),
|
||||
"kind": kind,
|
||||
"center_x": round(float(station_xy[index, 0]), 6),
|
||||
"center_y": round(float(station_xy[index, 1]), 6),
|
||||
|
||||
@@ -117,16 +117,20 @@ def station_keys(
|
||||
) -> dict[str, float]:
|
||||
"""측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리.
|
||||
|
||||
관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만) · id 없는 관 · 구조물은 `at:` ·
|
||||
관 · 구조물 자리 > 곡선 측점(`curve:` · 252-2) > 시종점 > 일반 격자(간격 배수인 것만) ·
|
||||
id 없는 관 · 구조물은 `at:` ·
|
||||
같은 열쇠가 둘이면 뒤엣것은 `at:`(B06 `stationKeysOf` 와 같음).
|
||||
"""
|
||||
owners = _facility_owners(pipes, structures)
|
||||
values = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None]
|
||||
rows = [row for row in stations if row.get("chainage_m") is not None]
|
||||
values = [float(row["chainage_m"]) for row in rows]
|
||||
out: dict[str, float] = {}
|
||||
for index, chainage in enumerate(values):
|
||||
owner = owners.get(round(chainage, 2))
|
||||
if owner is not None:
|
||||
key = station_key(owner[0], chainage, interval_m, owner[1])
|
||||
elif rows[index].get("kind") == "curve":
|
||||
key = station_key("curve", chainage, interval_m)
|
||||
elif index == 0:
|
||||
key = station_key("bp", chainage, interval_m)
|
||||
elif index == len(values) - 1:
|
||||
|
||||
@@ -348,7 +348,7 @@ export async function mountRouteEditor(
|
||||
locked: apexLock,
|
||||
picked,
|
||||
stationIntervalM,
|
||||
stations: stations(),
|
||||
...snap.ticks(plannedLine.length ? plannedLine : planned, curveInfo),
|
||||
});
|
||||
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
|
||||
syncCurveBar();
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
|
||||
*
|
||||
* 고름 연동(382-2) — 공용 고름 한 칸(`design_selection`) 구독: 일반측점 고름 = 그 측점 횡단 · 평면 법선 ·
|
||||
* 구조물 고름 = 평면 그 자리 마름모만 · 평면 눈금 클릭 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름).
|
||||
* 구조물 · 관 고름 = 그 측점 횡단(411-A · 법선은 평면 마커 몫) · 옮겨도 열쇠로 따라감 ·
|
||||
* 평면 눈금 클릭 · 마커 고름 풂 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름 = 판 그대로).
|
||||
* 보는 측점 = 측점 열쇠(grid:k) — 묶음 차례마다 열쇠로 다시 짚음(노선을 고쳐도 같은 측점).
|
||||
* 파란 법선 폭 = 그 측점 발자국 기록(가게 `footprint.records`) 비탈 끝 좌 · 우 · 깎기 여부(색).
|
||||
* ========================================================================== */
|
||||
@@ -74,23 +75,23 @@ export interface CrossWatch {
|
||||
/** 비탈 끝 깎기(절토) 여부 — 발자국 기록이 없으면 없음(옛 파랑 · 반폭). */
|
||||
leftCut?: boolean;
|
||||
rightCut?: boolean;
|
||||
/** 구조물 · 관 자리 — 법선 없이 마름모만. */
|
||||
/** 구조물 · 관 자리 — 법선은 평면 마커가 그림(399). */
|
||||
structure?: true;
|
||||
}
|
||||
|
||||
/** 공용 고름에 적는 이름 — 평면 눈금 클릭(제 알림은 거름). */
|
||||
export const PLAN_PICK_FROM = "b05-plan";
|
||||
|
||||
/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 포함). */
|
||||
/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 · 곡선 측점 포함). */
|
||||
const isGridStation = (station: PipelineStation): boolean =>
|
||||
station.kind === "grid" || station.kind === "end";
|
||||
station.kind === "grid" || station.kind === "end" || station.kind === "curve";
|
||||
|
||||
export interface CrossPreviewWindow {
|
||||
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
|
||||
open: (chainageM: number) => Promise<void>;
|
||||
/** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */
|
||||
refresh: () => Promise<void>;
|
||||
/** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 마름모. 고른 것 없으면 null. */
|
||||
/** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 `structure`. 고른 것 없으면 null. */
|
||||
watched: () => CrossWatch | null;
|
||||
}
|
||||
|
||||
@@ -247,7 +248,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
params.side.append(current.root, previous.root);
|
||||
const interval = (): number => params.request().station_interval_m;
|
||||
|
||||
/** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 고름(마름모만). */
|
||||
/** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 · 관 측점인가. */
|
||||
let watching: { key: string | null; chainageM: number; structure: boolean } | null = null;
|
||||
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */
|
||||
let shown: CrossPreviewResponse | null = null;
|
||||
@@ -269,7 +270,9 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
}
|
||||
|
||||
function redraw(): void {
|
||||
if (watching === null || watching.structure) return;
|
||||
if (watching === null) return;
|
||||
const station = located();
|
||||
if (station && watching.key === null) watching.key = station.key; // 묶음 뒤 열쇠로 붙듦(옮겨도 따라감)
|
||||
const route = pipe?.input("route") ?? null;
|
||||
if (route !== drawnRoute && shown)
|
||||
previous.show(
|
||||
@@ -284,7 +287,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
},
|
||||
);
|
||||
drawnRoute = route;
|
||||
const at = located()?.chainageM ?? watching.chainageM;
|
||||
const at = station?.chainageM ?? watching.chainageM;
|
||||
shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId));
|
||||
shownSource = shown && routeCrossSource(params.projectId, pipe, shown.chainage_m, "cur");
|
||||
if (shown) current.show(shown, interval(), shownSource);
|
||||
@@ -316,11 +319,11 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
};
|
||||
|
||||
/** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */
|
||||
function showStation(key: string | null, chainageM: number): void {
|
||||
function showStation(key: string | null, chainageM: number, structure = false): void {
|
||||
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
|
||||
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
|
||||
previous.show(null, interval());
|
||||
watching = { key, chainageM, structure: false };
|
||||
watching = { key, chainageM, structure };
|
||||
drawnRoute = pipe?.input("route") ?? null;
|
||||
shown = null;
|
||||
redraw();
|
||||
@@ -328,7 +331,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
if (panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1)
|
||||
}
|
||||
|
||||
/** 다른 보기(종단 · 3D · 목록 · 횡단 카드) 고름 → 일반측점이면 그 측점 횡단 · 구조물이면 마름모. */
|
||||
/** 다른 보기(종단 · 3D · 목록 · 횡단 카드 · 평면 마커) 고름 → 그 측점 횡단(구조물 · 관도 · 411-A). */
|
||||
function follow(selection: DesignSelection): void {
|
||||
attach();
|
||||
const at = selection.chainageM;
|
||||
@@ -341,10 +344,8 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
return;
|
||||
}
|
||||
const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM);
|
||||
if (!selection.structureId && (!station || isGridStation(station)))
|
||||
return showStation(station?.key ?? null, station?.chainageM ?? at);
|
||||
watching = { key: station?.key ?? null, chainageM: station?.chainageM ?? at, structure: true };
|
||||
params.onShown?.();
|
||||
const structure = !!selection.structureId || (!!station && !isGridStation(station));
|
||||
showStation(station?.key ?? null, station?.chainageM ?? at, structure);
|
||||
}
|
||||
const stopPick = subscribeSelection(params.projectId, (selection, from) => {
|
||||
if (from !== PLAN_PICK_FROM) follow(selection);
|
||||
|
||||
@@ -192,7 +192,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
let dragMoved = false;
|
||||
/** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */
|
||||
let dragRaw: Vertex[] | null = null;
|
||||
/** 구조물 측점 마커(399) — 집기 차례 = 교각점 · 손잡이 다음 · 끌기 · 방향키 1 m. */
|
||||
/** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */
|
||||
const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null);
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
@@ -206,6 +206,17 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
}
|
||||
const state = params.state();
|
||||
hover(null); // 누르는 동안 · 끄는 동안 맴돔 끔(381-3)
|
||||
// 구조물 마커가 먼저(409) — 교각점 · 손잡이와 겹쳐도 마커 · 고른 교각점은 풂(마커 방향키 몫)
|
||||
if (marks.press(px, py)) {
|
||||
if (state.picked >= 0) {
|
||||
params.setPicked(-1);
|
||||
params.syncCurveBar();
|
||||
params.draw();
|
||||
}
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
marks.deselect(); // 마커 아닌 곳 = 마커 고름 풂(오른쪽 횡단 판은 그대로)
|
||||
const pick = pickAt(px, py);
|
||||
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
|
||||
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
|
||||
@@ -239,8 +250,6 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
})()
|
||||
) {
|
||||
/* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */
|
||||
} else if (marks.press(px, py)) {
|
||||
/* 구조물 마커를 골랐다 — 끌면 노선 따라 1 m 씩(399). */
|
||||
} else if (
|
||||
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
|
||||
(() => {
|
||||
@@ -358,16 +367,19 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
const measuring = event.shiftKey || params.measureMode();
|
||||
if (measuring && params.measure.preview(px, py)) params.draw();
|
||||
// 맴돌 때 이길 쪽만 밝힘(381-3) — 누르면 집힐 것과 같은 셈 · 재는 중엔 끔.
|
||||
const over = measuring ? null : pickAt(px, py);
|
||||
// 마커 위면 마커 몫(409) — 밑에 깔린 교각점 · 눈금은 안 밝힘.
|
||||
const onMark = !measuring && marks.over(px, py);
|
||||
const over = measuring || onMark ? null : pickAt(px, py);
|
||||
hover(over);
|
||||
const overNode = over && "node" in over ? over.node : -1;
|
||||
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
|
||||
canvas.style.cursor =
|
||||
overNode >= 0
|
||||
canvas.style.cursor = onMark
|
||||
? "grab"
|
||||
: overNode >= 0
|
||||
? state.apexLock[overNode]
|
||||
? "not-allowed"
|
||||
: "grab"
|
||||
: handleAt(px, py) || marks.over(px, py)
|
||||
: handleAt(px, py)
|
||||
? "grab"
|
||||
: over
|
||||
? "pointer"
|
||||
|
||||
@@ -98,6 +98,8 @@ export interface RouteEditScene {
|
||||
stationIntervalM: number;
|
||||
/** 그릴 측점 누가거리 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */
|
||||
stations: ReadonlyArray<number> | null;
|
||||
/** 곡선 측점(MC · 252-2) 누가거리 — 눈금 이름 뒤 「(MC)」. */
|
||||
curveStations?: ReadonlyArray<number>;
|
||||
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */
|
||||
measure: ReadonlyArray<MeasureMark>;
|
||||
/** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */
|
||||
@@ -255,7 +257,6 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
// 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
|
||||
// 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
|
||||
drawContourLabels(context, scene, band, style);
|
||||
drawStructureMarks(context, scene, line, style); // 구조물 측점 마커(399) — 라벨 위 · 교각점 아래
|
||||
// 노드 · 손잡이는 라벨 뒤 맨 위(381-3) — 측점 · 기울기 · 높이 글자에 묻혀 안 집히던 자리.
|
||||
context.save();
|
||||
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
@@ -319,6 +320,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
context.restore();
|
||||
drawPickedCurve(context, scene, style);
|
||||
drawPickHover(context, scene, line, style, NODE_R);
|
||||
drawStructureMarks(context, scene, line, style); // 구조물 측점 마커 — 맨 위(409 · 교각점 · 손잡이 · 맴돔 위)
|
||||
context.restore(); // 회전 끝
|
||||
}
|
||||
|
||||
@@ -484,6 +486,7 @@ function drawStationMarks(
|
||||
uprightRad: scene.uprightRad,
|
||||
onLabel: (box) => labels.push(box),
|
||||
chainages: scene.stations ?? undefined,
|
||||
mcChainages: scene.curveStations,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* 저장 노선이 스냅 켬이면 열 때 그 간격으로 고르기를 맞춤. 추천 · 서버 초기 노선은 측점 No. 그대로.
|
||||
* 스냅 끔이면 고르기는 흐림(CSS) · 고를 수는 있음.
|
||||
* 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 그 밖 = 「1 m 배수에」 꼴.
|
||||
* 평면 눈금 = 일반측점(스냅 켬이면 옮긴 자리) + 곡선 측점(MC 에 측점 없을 때 · 252-2 · `ticks`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
@@ -16,6 +17,7 @@ import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
|
||||
import type { EditedCurve, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
curveSpans,
|
||||
curveStations,
|
||||
snapCurveStations,
|
||||
stationsAlong,
|
||||
} from "./logic/B05_Profile_UI_RouteEdit_Frame";
|
||||
@@ -52,6 +54,11 @@ export type SnapStep = {
|
||||
changed: () => boolean;
|
||||
/** 편집 창 측점 — 끔이면 null · 켬이면 호 안 하나를 MC 로(자릿수 = 간격 · 셈은 서버 짝 `_Frame`). */
|
||||
stations: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) => number[] | null;
|
||||
/** 평면 눈금 — `stations` + 곡선 측점(252-2) · 곡선 측점이 없으면 `stations` 그대로. */
|
||||
ticks: (
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<EditedCurve>,
|
||||
) => { stations: number[] | null; curveStations: number[] };
|
||||
};
|
||||
|
||||
/** 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈. */
|
||||
@@ -73,6 +80,14 @@ export function bindStationSnap(
|
||||
const step = (): number | null => (select.value === "station" ? null : Number(select.value));
|
||||
const draft = (): SnapSetting => ({ stationSnap: on(), stationSnapStep: on() ? step() : null });
|
||||
let loaded: SnapSetting = { stationSnap: false, stationSnapStep: null };
|
||||
const stations = (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) =>
|
||||
on()
|
||||
? snapCurveStations(
|
||||
stationsAlong(line, stationIntervalM, false),
|
||||
curveSpans(line, curves),
|
||||
step(),
|
||||
)
|
||||
: null;
|
||||
const stepM = () => step() ?? stationIntervalM;
|
||||
return Object.assign(stepM, {
|
||||
text: () => snapFitText(select.value),
|
||||
@@ -90,13 +105,15 @@ export function bindStationSnap(
|
||||
now.stationSnap !== loaded.stationSnap || now.stationSnapStep !== loaded.stationSnapStep
|
||||
);
|
||||
},
|
||||
stations: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) =>
|
||||
on()
|
||||
? snapCurveStations(
|
||||
stationsAlong(line, stationIntervalM, false),
|
||||
curveSpans(line, curves),
|
||||
step(),
|
||||
)
|
||||
: null,
|
||||
stations,
|
||||
ticks: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) => {
|
||||
const base = stations(line, curves) ?? stationsAlong(line, stationIntervalM, false);
|
||||
// 노선 길이 = 무한 — 호(BC~EC)는 늘 노선 안
|
||||
const curve = curveStations(base, curveSpans(line, curves), Infinity, on() ? step() : null);
|
||||
return {
|
||||
stations: curve.length ? [...base, ...curve].sort((a, b) => a - b) : stations(line, curves),
|
||||
curveStations: curve,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,13 +8,19 @@
|
||||
* 고른 마커 = 알약 벌룬과 같은 창(`balloonElement` · `balloonLines`) + 그 측점 좌우 20 m 법선(391).
|
||||
* · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 ·
|
||||
* 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본).
|
||||
* · 집기 차례 = 교각점 · 곡선 손잡이 다음(381-3) — `_Pointer` 가 부름.
|
||||
* · 그림 · 집기 차례 = 맨 위 · 맨 앞(409 · 재기 다음) — `_Render` · `_Pointer` 가 부름.
|
||||
* · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로).
|
||||
* 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import { draftProjectId } from "../A00_Common/design/design_store";
|
||||
import { PIPE_MATCH_M, readSelection } from "../A00_Common/design/design_selection";
|
||||
import {
|
||||
NO_SELECTION,
|
||||
PIPE_MATCH_M,
|
||||
readSelection,
|
||||
selectDesign,
|
||||
} from "../A00_Common/design/design_selection";
|
||||
import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
|
||||
import {
|
||||
structureAnchorM,
|
||||
@@ -32,6 +38,7 @@ import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit";
|
||||
import { drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover";
|
||||
import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer";
|
||||
import { PLAN_PICK_FROM } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
|
||||
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
@@ -179,7 +186,7 @@ function paintMark(
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 라벨 뒤 · 교각점 아래에 부름. */
|
||||
/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 맨 마지막에 부름(409). */
|
||||
export function drawStructureMarks(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
@@ -250,7 +257,7 @@ function showBalloon(
|
||||
}
|
||||
|
||||
export interface StructureMarkInput {
|
||||
/** 마커를 눌렀으면 고르고 끌기 시작 · true(교각점 · 손잡이 다음 차례). */
|
||||
/** 마커를 눌렀으면 고르고 끌기 시작 · true(재기 다음 맨 앞 · 409). */
|
||||
press: (px: number, py: number) => boolean;
|
||||
/** 끄는 중이면 자리 바꿈 · true. */
|
||||
move: (px: number, py: number) => boolean;
|
||||
@@ -258,6 +265,8 @@ export interface StructureMarkInput {
|
||||
release: () => boolean;
|
||||
/** 마커 위인가 — 커서 몫. */
|
||||
over: (px: number, py: number) => boolean;
|
||||
/** 고른 마커가 있으면 풂 · true(409 — 마커 아닌 곳 누름 · Esc). */
|
||||
deselect: () => boolean;
|
||||
}
|
||||
|
||||
/** 끌기 · 방향키 배선 — `_Pointer` 가 부름(같은 재료 `params`). */
|
||||
@@ -321,6 +330,14 @@ export function bindStructureMarks(
|
||||
return moving;
|
||||
};
|
||||
|
||||
/** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판은 제 이름(`PLAN_PICK_FROM`)이라 안 비움. */
|
||||
const deselect = (): boolean => {
|
||||
if (!planMarks(canvas)?.picked) return false;
|
||||
selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });
|
||||
params.draw();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 방향키 — 마커를 골랐고 교각점을 안 골랐을 때 · 누름 ~ 뗌 한 번이 한 옮김(교각점 방향키와 같은 반복)
|
||||
let holdKey: ArrowKey | null = null;
|
||||
let timer = 0;
|
||||
@@ -351,9 +368,12 @@ export function bindStructureMarks(
|
||||
};
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (!canvas.isConnected) return detach();
|
||||
if (!isArrowKey(event.key) || !lastPointerWithin(".b05-routeedit")) return;
|
||||
const escape = event.key === "Escape";
|
||||
if ((!escape && !isArrowKey(event.key)) || !lastPointerWithin(".b05-routeedit")) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return;
|
||||
// Esc = 마커 고름만 풂(공용 Esc 풀기는 오른쪽 판까지 비우므로 앞에서 받음)
|
||||
if (escape) return void (!activeMoving(canvas) && deselect() && event.preventDefault());
|
||||
if (event.repeat || holdKey || dragging() || params.measureMode()) return;
|
||||
if (params.state().picked >= 0 || activeMoving(canvas)) return; // 교각점 방향키 몫
|
||||
const found = planMarks(canvas);
|
||||
@@ -401,5 +421,6 @@ export function bindStructureMarks(
|
||||
},
|
||||
release: () => !holdKey && finish(),
|
||||
over: (px, py) => markAt(px, py) !== null,
|
||||
deselect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,7 +121,9 @@ const kindOf = (station: PipelineStation): SectionStation["kind"] =>
|
||||
? "ep"
|
||||
: station.kind === "grid"
|
||||
? "regular"
|
||||
: "irregular";
|
||||
: station.kind === "curve"
|
||||
? "curve"
|
||||
: "irregular";
|
||||
|
||||
/** 제원의 누가거리 = 새 측점(서버 부착은 따라간 관 · 구조물 자리 = 그 측점) — 옛 값이면 구조물 면적이 관 자리를 잘못 잡음(349-0n). */
|
||||
function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 계약 = `A00_Common/design/logic/design_pipeline_types.ts` 머리).
|
||||
*
|
||||
* ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` `generate_sections` 의 측점 부분
|
||||
* (격자 · 스냅 · 구조물 측점 병합 · 종류 · 틀 · 방위각) ↔ `buildStations` ·
|
||||
* (격자 · 스냅 · 구조물 측점 병합 · 곡선 측점 · 종류 · 틀 · 방위각) ↔ `buildStations` ·
|
||||
* `_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `planRadii` · `curveWidenings` ·
|
||||
* `designCurveWidenings` · `curve_widening_m` · `CURVE_WIDENING_TABLE_M` 등(config_system_design) ↔
|
||||
* 같은 이름 상수 · `circumradius_2d`(Engine_Geometry) ↔ `circumradius`.
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import {
|
||||
curveSpans,
|
||||
curveStations,
|
||||
snapCurveStations,
|
||||
stationFrameAt,
|
||||
stationsAlong,
|
||||
@@ -67,7 +68,8 @@ const round = (value: number, digits: number): number => Number(value.toFixed(di
|
||||
export const stationText = (chainageM: number): string => chainageM.toFixed(2);
|
||||
|
||||
/** 측점 열쇠 — 격자 = `grid:k`(k = 원래 격자 번호 · 노선 모양이 바뀌어도 같은 번호) · 관 = `pipe:<id>` ·
|
||||
* 구조물 = `structure:<id>`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP`.
|
||||
* 구조물 = `structure:<id>`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP` ·
|
||||
* 곡선 = `curve:<누가거리>`(ref = 누가거리).
|
||||
* 규칙 한 벌 = `common_util_station_key`(파이썬 짝) — 여기는 꼴만 옮김. */
|
||||
export function stationKey(
|
||||
kind: StationKind,
|
||||
@@ -76,6 +78,7 @@ export function stationKey(
|
||||
): string {
|
||||
if (kind === "end") return commonStationKey(ref === "BP" ? "bp" : "ep", 0, null);
|
||||
if (kind === "grid") return commonStationKey("regular", Number(ref), 1); // ref = 격자 번호(간격 1)
|
||||
if (kind === "curve") return commonStationKey("curve", Number(ref), null);
|
||||
const base = commonStationKey(kind, 0, null, String(ref));
|
||||
// ponytail: 구간형 꼬리 — sub2 가 공용 함수에 넣으면 그 인자로 옮김
|
||||
return role === "anchor" ? base : `${base}:${role}`;
|
||||
@@ -308,7 +311,15 @@ export function buildStations(
|
||||
role: station.role,
|
||||
});
|
||||
}
|
||||
const chainages = [...new Set([...grid, ...planted.keys()])].sort((a, b) => a - b);
|
||||
const withPlanted = [...new Set([...grid, ...planted.keys()])];
|
||||
// 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m) · 격자 번호 없음
|
||||
const curved = curveStations(
|
||||
withPlanted,
|
||||
spans.map(([bc, ec]) => [bc, ec] as const),
|
||||
total,
|
||||
options.stationSnap ? options.stationSnapStep : null,
|
||||
);
|
||||
const chainages = [...withPlanted, ...curved].sort((a, b) => a - b);
|
||||
|
||||
const fromSpans = spans.length ? designCurveWidenings(chainages, spans) : null;
|
||||
const measured = fromSpans ? null : planRadii(line, chainages, total);
|
||||
@@ -329,6 +340,9 @@ export function buildStations(
|
||||
} else if (mark) {
|
||||
kind = mark.kind;
|
||||
key = stationKey(mark.kind, mark.id, mark.role as "start" | "anchor" | "end");
|
||||
} else if (curved.includes(chainage)) {
|
||||
kind = "curve";
|
||||
key = stationKey("curve", chainage);
|
||||
}
|
||||
return {
|
||||
key,
|
||||
|
||||
@@ -181,3 +181,24 @@ export function snapCurveStations(
|
||||
}
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 곡선 측점(252-2) — MC(floor(MC·s+0.5)/s)에 측점이 없으면 그 값 · 0.005 m 안 측점(일반 · 옮긴 · 관 · 구조물)이
|
||||
* 있거나 노선 밖이면 뺌 · BC · EC 는 안 더함(오름차순). `curve_stations` 짝. */
|
||||
export function curveStations(
|
||||
stations: ReadonlyArray<number>,
|
||||
spans: ReadonlyArray<readonly [number, number]>,
|
||||
total: number,
|
||||
stepM?: number | null,
|
||||
): number[] {
|
||||
const scale = mcScale(stepM);
|
||||
const taken = [...stations];
|
||||
const out: number[] = [];
|
||||
for (const [start, end] of spans) {
|
||||
const value = Math.floor(((start + end) / 2) * scale + 0.5) / scale;
|
||||
if (!(value > 0 && value < total) || taken.some((other) => Math.abs(value - other) < 0.005))
|
||||
continue;
|
||||
taken.push(value);
|
||||
out.push(value);
|
||||
}
|
||||
return out.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
@@ -115,8 +115,8 @@ export interface SectionStation {
|
||||
station_id: string;
|
||||
chainage_m: number;
|
||||
label: string;
|
||||
/** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속). */
|
||||
kind: "bp" | "ep" | "regular" | "irregular";
|
||||
/** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속) · curve = 곡선 측점(MC · 252-2). */
|
||||
kind: "bp" | "ep" | "regular" | "irregular" | "curve";
|
||||
/** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */
|
||||
structure?: string;
|
||||
/** 구조물 측점이 얹힌 격자 자리(같은 m 면 측점 하나 · 구조물이 위 · 272) — 구조물이 떠나면 일반 측점. */
|
||||
|
||||
@@ -92,7 +92,8 @@ export function appendCardHeader(
|
||||
const title = document.createElement("div");
|
||||
title.className = "b06-cross-card__title";
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = stationLabel(section.chainage_m, stationInterval);
|
||||
label.textContent =
|
||||
stationLabel(section.chainage_m, stationInterval) + (section.kind === "curve" ? " (MC)" : "");
|
||||
const chainage = document.createElement("span");
|
||||
chainage.textContent = `${section.chainage_m.toFixed(1)}m`;
|
||||
title.append(label, chainage);
|
||||
|
||||
@@ -428,12 +428,16 @@ export function createLongitudinalProfile(
|
||||
}),
|
||||
// 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점
|
||||
// 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시).
|
||||
svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), {
|
||||
x: stationX,
|
||||
y: heightPx - bottomInsetPx - 5,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__station-label",
|
||||
}),
|
||||
svgText(
|
||||
offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset) +
|
||||
(station.kind === "curve" ? " (MC)" : ""),
|
||||
{
|
||||
x: stationX,
|
||||
y: heightPx - bottomInsetPx - 5,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__station-label",
|
||||
},
|
||||
),
|
||||
);
|
||||
// 구조물(비정규) 측점: 구조물 이름으로 위치를 식별할 수 있게 한다.
|
||||
// 플롯 **하단**(측점 라벨 바로 위)에 둔다 — 상단은 그래프 이름표와 절·성토 표기가
|
||||
|
||||
@@ -482,7 +482,8 @@ html.is-bm-route .b06-section__notice-bar {
|
||||
}
|
||||
|
||||
.b06-chart__station-line--bp,
|
||||
.b06-chart__station-line--regular {
|
||||
.b06-chart__station-line--regular,
|
||||
.b06-chart__station-line--curve {
|
||||
stroke: var(--color-warning);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ const freshDesign = () =>
|
||||
|
||||
function kindOf(station: PipelineStation): SectionStation["kind"] {
|
||||
if (station.kind === "end") return station.key.endsWith("EP") ? "ep" : "bp";
|
||||
if (station.kind === "curve") return "curve";
|
||||
return station.kind === "grid" ? "regular" : "irregular";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름.
|
||||
|
||||
일반 = ``grid:k``(누가거리 ÷ 측점 간격 반올림) · 관 = ``pipe:<id>`` · 구조물 = ``structure:<id>`` ·
|
||||
시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>``.
|
||||
시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>`` ·
|
||||
곡선 측점(MC · 252-2) = ``curve:<누가거리 0.01 m>``.
|
||||
구간형 구조물 시작 · 끝 측점 = ``structure:<id>:start`` · ``:end``(기준 측점은 ``structure:<id>``).
|
||||
한 목록에 같은 열쇠가 둘이면 둘째부터 ``at:<누가거리>``(``dedupe_station_keys``).
|
||||
|
||||
@@ -50,6 +51,8 @@ def station_key(
|
||||
return (
|
||||
f"end:{station_id if station_id is not None else ('BP' if chainage_m <= 0 else 'EP')}"
|
||||
)
|
||||
if kind == "curve":
|
||||
return f"curve:{chainage_key(chainage_m)}"
|
||||
if kind in ("pipe", "structure"):
|
||||
return f"{kind}:{station_id}{suffix}" if station_id else loose
|
||||
if kind == "irregular":
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름.
|
||||
* 일반 = `grid:k`(누가거리 ÷ 측점 간격 반올림) · 관 = `pipe:<id>` · 구조물 = `structure:<id>` ·
|
||||
* 시종점 = `end:BP` · `end:EP` · id 를 모르는 구조물 측점 = `at:<누가거리 0.01 m>`(옛 초안 열쇠와 같은 몫).
|
||||
* 곡선 측점(MC · 252-2) = `curve:<누가거리 0.01 m>`.
|
||||
* 구간형 구조물의 시작 · 끝 측점 = `structure:<id>:start` · `:end`(기준 측점은 `structure:<id>` 그대로).
|
||||
* 한 목록에 같은 열쇠가 둘이면 둘째부터 `at:<누가거리>`(`dedupeStationKeys`).
|
||||
* 쓰는 곳 = 서버 `B05_Profile_Route_Save_Parts` · 묶음 stations 단계 · B06 세션 횡단 초안(`stationKeysOf`).
|
||||
@@ -11,9 +12,9 @@
|
||||
* 고치고 거울 시험 `resources/tester/test_344_3_station_key_mirror.py`.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular) · 묶음 측점 `kind`(grid · end · pipe · structure). */
|
||||
/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular · curve) · 묶음 측점 `kind`(grid · end · pipe · structure · curve). */
|
||||
export type StationKeyKind =
|
||||
"bp" | "ep" | "regular" | "irregular" | "grid" | "end" | "pipe" | "structure";
|
||||
"bp" | "ep" | "regular" | "irregular" | "grid" | "end" | "pipe" | "structure" | "curve";
|
||||
|
||||
/** 구간형 구조물 측점 자리 — 기준(anchor) · 시작 · 끝. */
|
||||
export type StationKeyRole = "anchor" | "start" | "end";
|
||||
@@ -40,6 +41,7 @@ export function stationKey(
|
||||
if (kind === "bp") return "end:BP";
|
||||
if (kind === "ep") return "end:EP";
|
||||
if (kind === "end") return `end:${id ?? (chainageM <= 0 ? "BP" : "EP")}`;
|
||||
if (kind === "curve") return `curve:${chainageKey(chainageM)}`;
|
||||
if (kind === "pipe" || kind === "structure") return id ? `${kind}:${id}${suffix}` : loose;
|
||||
if (kind === "irregular") return id ? `${idKind}:${id}${suffix}` : loose;
|
||||
if (kind === "grid" && id !== null) return `grid:${id}`;
|
||||
|
||||
@@ -46,6 +46,7 @@ const out = {
|
||||
return logic.buildStations(c.line, c.curves, job.pipes, job.structures, {
|
||||
types: job.types,
|
||||
stationSnap: c.snap,
|
||||
stationSnapStep: c.step ?? null, // 390-5 · 252-2 곡선 측점 값 자릿수
|
||||
});
|
||||
}),
|
||||
text: [0, 12.345, 40, 699.996].map(logic.stationText),
|
||||
|
||||
@@ -116,7 +116,11 @@ function realStages(savedBase, remote = null) {
|
||||
return {
|
||||
route: routeGround.createRouteStage({
|
||||
minRadiusM: () => job.minRadius,
|
||||
savedLine: () => ({ line: job.line, curves: [] }),
|
||||
// 저장 노선 곡선표 = 같은 노드의 편집 곡선(407 곡선 측점 · 빈 표면 옮긴 뒤에만 MC 측점이 생김)
|
||||
savedLine: () => ({
|
||||
line: job.line,
|
||||
curves: routeGround.draftLine(draftOf(job.nodes), job.minRadius).curves,
|
||||
}),
|
||||
}),
|
||||
follow: grade.followStage,
|
||||
stations: stationsMod.createStationsStage({ types: () => job.types }),
|
||||
|
||||
@@ -36,10 +36,19 @@ global.window = {
|
||||
clearTimeout: () => {},
|
||||
clearInterval: () => {},
|
||||
};
|
||||
let prevented = 0;
|
||||
const key = (type, name) =>
|
||||
(listeners[type] || []).forEach((fn) =>
|
||||
fn({ key: name, repeat: false, target: null, preventDefault() {}, stopPropagation() {} }),
|
||||
fn({
|
||||
key: name,
|
||||
repeat: false,
|
||||
target: null,
|
||||
preventDefault: () => (prevented += 1),
|
||||
stopPropagation() {},
|
||||
}),
|
||||
);
|
||||
/** 공용 고름에 적은 것(409 풀기) — [값, 낸 쪽]. */
|
||||
const written = [];
|
||||
|
||||
const STUBS = {
|
||||
"../A00_Common/touch_bridge": {
|
||||
@@ -50,7 +59,16 @@ const STUBS = {
|
||||
"../A00_Common/input_prefs": { isTrackpadPinch: () => false },
|
||||
"../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 16 },
|
||||
"../A00_Common/design/design_store": { draftProjectId: () => "p1" },
|
||||
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51, readSelection: () => selection },
|
||||
"../A00_Common/design/design_selection": {
|
||||
PIPE_MATCH_M: 0.51,
|
||||
NO_SELECTION: { chainageM: null, structureId: null },
|
||||
readSelection: () => selection,
|
||||
selectDesign: (projectId, value, options) => {
|
||||
written.push([value.chainageM, options.from]);
|
||||
selection = value.chainageM === null ? null : value;
|
||||
},
|
||||
},
|
||||
"./B05_Profile_UI_RouteEdit_Cross": { PLAN_PICK_FROM: "b05-plan" },
|
||||
"../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true },
|
||||
"./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 },
|
||||
"./B05_Profile_Api_Structures": {
|
||||
@@ -315,7 +333,24 @@ const was = redrawn;
|
||||
marks.planMarksChanged("p1");
|
||||
out.redrawnOnChange = redrawn - was;
|
||||
|
||||
// ⑥ 순수 셈
|
||||
// ⑥ 고름 풀기(409) — 고른 것 없으면 아무 일 없음 · 있으면 공용 고름을 b05-plan 으로 비움 · Esc 도
|
||||
selection = null;
|
||||
out.deselectNone = [input.deselect(), written.length];
|
||||
selection = { chainageM: 40, structureId: "A" };
|
||||
out.deselectPicked = [input.deselect(), ...written.slice(-1), selection];
|
||||
selection = { chainageM: 80.17, structureId: null }; // 관 고름(누가거리만)
|
||||
prevented = 0;
|
||||
key("keydown", "Escape");
|
||||
out.escapePicked = [...written.slice(-1), selection, prevented];
|
||||
const writes = written.length;
|
||||
key("keydown", "Escape"); // 고른 것 없음 = 공용 Esc 몫(안 막음)
|
||||
out.escapeNone = [written.length - writes, prevented];
|
||||
// 마커 누름 = 고름(공용 고름 길) · 법선 · 창 — 그린 차례는 `_Render` 맨 끝(시험이 원본으로 봄)
|
||||
input.press(80, 0);
|
||||
input.release();
|
||||
out.pressAfterRelease = selects[selects.length - 1];
|
||||
|
||||
// ⑦ 순수 셈
|
||||
out.logic = {
|
||||
round: [logic.markChainage(41.4, 200), logic.markChainage(41.6, 200)],
|
||||
clamp: [logic.markChainage(-3, 200), logic.markChainage(250, 199.6)],
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/* 411-A B05 평면 오른쪽 횡단 판 — 구조물 · 관을 골라도 그 측점 횡단 · 옮긴 뒤에도 열쇠로 따라감.
|
||||
* `_RouteEdit_Cross` 를 Node 로 돌려 JSON 한 줄(`test_411_a_cross_structure.py` 가 판정). 묶음 · 그림 · 가게는 가짜 ·
|
||||
* 고름 구독 · 측점 짚기 · 판 채우기는 진짜.
|
||||
* node helper_411_a_cross_structure.cjs
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const B05 = path.join(__dirname, "..", "..", "B05_Profile");
|
||||
const out = {};
|
||||
|
||||
/* 가짜 DOM — 판(제목 · 상자 · 발) 글만 봄 */
|
||||
function fakeElement() {
|
||||
const parts = new Map();
|
||||
const element = {
|
||||
textContent: "",
|
||||
children: [],
|
||||
isConnected: true,
|
||||
clientHeight: 150,
|
||||
setAttribute() {},
|
||||
append: (...items) => element.children.push(...items),
|
||||
replaceChildren: (...items) => (element.children = items),
|
||||
getBoundingClientRect: () => ({ width: 240, height: 150 }),
|
||||
querySelector: (selector) => {
|
||||
if (!parts.has(selector)) parts.set(selector, fakeElement());
|
||||
return parts.get(selector);
|
||||
},
|
||||
};
|
||||
return element;
|
||||
}
|
||||
global.document = { createElement: fakeElement };
|
||||
global.ResizeObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
let listener = null;
|
||||
let stations = [];
|
||||
const route = { id: 1 };
|
||||
const subscribers = [];
|
||||
const pipe = {
|
||||
output: (stage) => (stage === "ground" ? { stations } : {}),
|
||||
input: () => route,
|
||||
subscribe: (fn) => (subscribers.push(fn), () => {}),
|
||||
};
|
||||
const crossStage = () => subscribers.forEach((fn) => fn({ kind: "stage", stage: "cross" }));
|
||||
|
||||
const STUBS = {
|
||||
"../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan() {} },
|
||||
"../A00_Common/design/design_pipeline_page": { pagePipeline: () => pipe },
|
||||
"../A00_Common/design/design_selection": {
|
||||
nearestWithin: (items, at, of) => {
|
||||
let best;
|
||||
let gap = 0.51;
|
||||
for (const item of items)
|
||||
if (Math.abs(of(item) - at) < gap) [best, gap] = [item, Math.abs(of(item) - at)];
|
||||
return best;
|
||||
},
|
||||
selectDesign() {},
|
||||
subscribeSelection: (_projectId, fn) => ((listener = fn), () => {}),
|
||||
},
|
||||
"../A00_Common/design/design_store": {
|
||||
useDesignStore: () => ({ subscribe: () => () => {}, get: () => null }),
|
||||
},
|
||||
"./logic/B05_Profile_Pipeline_Detail": {
|
||||
stationSection: (station) => ({ chainage_m: station.chainageM, samples: [] }),
|
||||
},
|
||||
"./B05_Profile_UI_RouteEdit_Cross_Draw": {
|
||||
buildCrossSvg: () => ({ svg: fakeElement(), layer: null, width: 240, height: 150 }),
|
||||
CROSS_PLOT_PAD: 0,
|
||||
summarizeCross: (preview) => `횡단 ${preview.chainage_m}`,
|
||||
},
|
||||
"../A00_Common/b_page_state": { readStateRaw: () => null },
|
||||
"../A00_Common/design/design_page_route": { pageRouteId: () => null },
|
||||
"../common_util/common_util_station": { stationLabel: (chainageM) => String(chainageM) },
|
||||
"./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new Map() },
|
||||
"./B05_Profile_UI_RouteEdit_Cross_Card": {
|
||||
routeCrossCard: () => fakeElement(),
|
||||
routeCrossSource: () => null,
|
||||
},
|
||||
};
|
||||
const cache = new Map();
|
||||
function loadTs(full) {
|
||||
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) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const cross = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Cross.ts"));
|
||||
const station = (key, chainageM, kind, id = null) => ({ key, chainageM, kind, id });
|
||||
const grid = [
|
||||
station("end:BP", 0, "end"),
|
||||
station("grid:1", 20, "grid"),
|
||||
station("grid:2", 40, "grid"),
|
||||
];
|
||||
const side = fakeElement();
|
||||
const preview = cross.createCrossPreview({
|
||||
projectId: "p1",
|
||||
side,
|
||||
request: () => ({ vertices: [], min_radius_m: 15, station_interval_m: 20 }),
|
||||
});
|
||||
const [current] = side.children;
|
||||
const shownNow = () => ({
|
||||
station: current.querySelector(".b05-routeedit__cross-station").textContent,
|
||||
foot: current.querySelector(".b05-routeedit__cross-foot").textContent,
|
||||
});
|
||||
const pick = (chainageM, structureId, from) => listener({ chainageM, structureId }, from);
|
||||
|
||||
// ① 구조물 고름(id) = 그 측점 횡단 · 법선은 마커 몫(structure)
|
||||
stations = [
|
||||
...grid,
|
||||
station("structure:A", 45, "structure", "A"),
|
||||
station("pipe:p1", 80.17, "pipe", "p1"),
|
||||
];
|
||||
pick(45, "A", "b05");
|
||||
out.structurePick = { ...shownNow(), structure: preview.watched()?.structure ?? null };
|
||||
// ② 옮김 — 묶음이 새 자리로(같은 열쇠) · cross 차례 = 새 자리 횡단(고름은 옛 45 그대로)
|
||||
stations = [
|
||||
...grid,
|
||||
station("structure:A", 52, "structure", "A"),
|
||||
station("pipe:p1", 80.17, "pipe", "p1"),
|
||||
];
|
||||
crossStage();
|
||||
out.structureMoved = { ...shownNow(), at: preview.watched()?.chainageM ?? null };
|
||||
// ③ 관 고름(누가거리만) = 그 측점 횡단
|
||||
pick(80.17, null, "b05");
|
||||
out.pipePick = { ...shownNow(), structure: preview.watched()?.structure ?? null };
|
||||
// ④ 평면 마커 고름 풂(b05-plan) = 판 그대로
|
||||
pick(null, null, "b05-plan");
|
||||
out.planRelease = shownNow();
|
||||
// ⑤ 일반측점 고름 = 그 측점 횡단 · 법선(structure 없음)
|
||||
pick(20, null, "b05");
|
||||
out.gridPick = { ...shownNow(), structure: preview.watched()?.structure ?? null };
|
||||
// ⑥ 묶음 전 구조물 고름 — 열쇠 없이 기다렸다 묶음 뒤 열쇠로 붙듦 · 그 뒤 옮김도 따라감
|
||||
stations = [];
|
||||
pick(60, "B", "b05");
|
||||
out.beforePipeline = shownNow();
|
||||
stations = [...grid, station("structure:B", 60, "structure", "B")];
|
||||
crossStage();
|
||||
out.afterPipeline = shownNow();
|
||||
stations = [...grid, station("structure:B", 66, "structure", "B")];
|
||||
crossStage();
|
||||
out.latchedMoved = shownNow();
|
||||
// ⑦ 다른 보기가 풂(null · b05) = 판 비움(382-2 그대로)
|
||||
pick(null, null, "b05");
|
||||
out.otherRelease = { ...shownNow(), watched: preview.watched() };
|
||||
console.log(JSON.stringify(out));
|
||||
@@ -241,7 +241,15 @@ def test_켬은_640_없고_631_있음() -> None:
|
||||
assert 640.0 not in by_chainage and 631.0 in by_chainage
|
||||
assert by_chainage[631.0]["kind"] == "regular"
|
||||
assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경
|
||||
assert len(by_chainage) == len(_stations(False))
|
||||
# 끔 = 640 남고 MC(631)에 곡선 측점 하나 더(252-2) · 켬 = 640 을 631 로 옮김(측점 수 그대로)
|
||||
assert len(by_chainage) == len(_stations(False)) - 1
|
||||
|
||||
|
||||
def test_끔은_MC_에_곡선_측점() -> None:
|
||||
by_chainage = {s["chainage_m"]: s for s in _stations(False)}
|
||||
assert by_chainage[640.0]["kind"] == "regular"
|
||||
assert by_chainage[631.0]["kind"] == "curve"
|
||||
assert by_chainage[631.0]["label"].endswith(" (MC)")
|
||||
|
||||
|
||||
def test_631_에_관이면_grid() -> None:
|
||||
|
||||
@@ -192,7 +192,13 @@ def _close(a, b) -> bool:
|
||||
return abs(float(a) - float(b)) <= _TOL
|
||||
|
||||
|
||||
_KIND = {"bp": {"end"}, "ep": {"end"}, "regular": {"grid"}, "irregular": {"pipe", "structure"}}
|
||||
_KIND = {
|
||||
"bp": {"end"},
|
||||
"ep": {"end"},
|
||||
"regular": {"grid"},
|
||||
"irregular": {"pipe", "structure"},
|
||||
"curve": {"curve"}, # 252-2 곡선 측점(MC)
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES])
|
||||
|
||||
@@ -47,6 +47,9 @@ CASES = [
|
||||
("irregular", 1.005, 20.0, None, "structure"),
|
||||
("pipe", 12.344, 20.0, "pipe-s1", "structure"),
|
||||
("structure", 12.345, 20.0, None, "structure"),
|
||||
# 252-2 곡선 측점(MC) — curve:<누가거리 0.01 m>
|
||||
("curve", 107.0, 20.0, None, "structure"),
|
||||
("curve", 107.35, 20.0, None, "structure"),
|
||||
]
|
||||
DEDUPE = (["grid:1", "structure:st-1", "grid:1", "structure:st-1"], [20.0, 25.0, 20.004, 25.006])
|
||||
LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"]
|
||||
|
||||
@@ -65,18 +65,23 @@ def test_공용_고름과_같은_길() -> None:
|
||||
assert " onStructureSelect,\n onStructureMarkMove,\n" in view # 알약 콜백 = 같은 함수
|
||||
assert "select: onStructureSelect," in view and "move: onStructureMarkMove," in view
|
||||
render = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Render.ts")
|
||||
labels = render.index("drawContourLabels(context, scene, band, style);\n drawStructureMarks")
|
||||
labels = render.index("drawContourLabels(context, scene, band, style);")
|
||||
nodes = render.index("scene.planned.forEach((vertex, index) => {")
|
||||
assert labels < nodes # 라벨 위 · 교각점 아래
|
||||
hover = render.index("drawPickHover(context, scene, line, style, NODE_R);")
|
||||
marks = render.index("drawStructureMarks(context, scene, line, style);")
|
||||
turned = render.index("context.restore(); // 회전 끝")
|
||||
assert labels < nodes < hover < marks < turned # 맨 위(409 — 교각점 · 손잡이 · 맴돔 위)
|
||||
|
||||
|
||||
def test_집기_차례는_교각점_손잡이_다음() -> None:
|
||||
def test_집기_차례는_재기_다음_맨_앞() -> None:
|
||||
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
|
||||
measure = pointer.index("void params.measure.pick(px, py);")
|
||||
mark = pointer.index("if (marks.press(px, py)) {")
|
||||
release = pointer.index("marks.deselect();")
|
||||
node = pointer.index("if (hitNode >= 0) {")
|
||||
handle = pointer.index("} else if (dragHandle) {")
|
||||
mark = pointer.index("} else if (marks.press(px, py)) {")
|
||||
tick = pointer.index('if (!pick || !("chainage" in pick)) return false;')
|
||||
assert node < handle < mark < tick
|
||||
assert measure < mark < release < node < handle < tick # 409
|
||||
|
||||
|
||||
def test_끌기_1m_정수_놓을_때_한_번(got) -> None:
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""PLAN 407 · 요구 252-2 — 곡선 측점(MC 에 측점이 없으면 하나 더함).
|
||||
|
||||
짝: 파이썬 `B05_Profile_Engine_Sections_Core.curve_stations` · `generate_sections` ↔ TS
|
||||
`logic/B05_Profile_UI_RouteEdit_Frame.ts` `curveStations` · `logic/B05_Profile_Sections_Stations.ts`
|
||||
`buildStations`(헬퍼 `helper_344_3_pipeline_stations.cjs`). 열쇠 `curve:<누가거리>` ·
|
||||
이름 뒤 「 (MC)」 ·
|
||||
값 = 스냅 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 · 스냅 끔 = 정수) · 일반측점 값과 같으면 안 더함 ·
|
||||
스냅 켬 · 호 안 일반측점 = 224-2 대로 옮김(곡선 측점 없음) · BC · EC 안 더함.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
|
||||
from common_util.common_util_station_key import station_key # noqa: E402
|
||||
|
||||
LINE = [[0.0, 0.0], [1000.3, 0.0]]
|
||||
LONG = {"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0}
|
||||
SHORT = {"start": [101.0, 0.0], "apex": [107.3, 2.0], "end": [113.6, 0.0], "radius_m": 30.0}
|
||||
ON_GRID = {"start": [195.0, 0.0], "apex": [200.1, 2.0], "end": [205.2, 0.0], "radius_m": 30.0}
|
||||
CURVES = [LONG, SHORT, ON_GRID]
|
||||
GRID = [float(value) for value in range(0, 1001, 20)]
|
||||
|
||||
|
||||
class _Flat:
|
||||
def sample_xy(self, xy):
|
||||
count = len(np.asarray(xy, dtype=np.float64))
|
||||
return np.full(count, 100.0), np.ones(count, dtype=bool)
|
||||
|
||||
|
||||
def _server(snap: bool, step: float | None = None, extras: tuple = ()) -> list[dict]:
|
||||
data = core.generate_sections(
|
||||
LINE,
|
||||
_Flat(),
|
||||
core.SectionGenerationOptions(extra_stations=extras),
|
||||
design_curves=CURVES,
|
||||
station_snap=snap,
|
||||
station_snap_step=step,
|
||||
)
|
||||
return data["longitudinal"]["stations"]
|
||||
|
||||
|
||||
def _curves(stations: list[dict]) -> list[float]:
|
||||
return [s["chainage_m"] for s in stations if s["kind"] == "curve"]
|
||||
|
||||
|
||||
# ── 파이썬 규칙 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_정수_MC_하나() -> None:
|
||||
assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3) == [107.0]
|
||||
|
||||
|
||||
def test_스냅_간격_0_1_이면_소수_한_자리() -> None:
|
||||
assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3, 0.1) == [107.3]
|
||||
assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3, 10.0) == [107.0]
|
||||
|
||||
|
||||
def test_일반측점_값이면_안_더함() -> None:
|
||||
assert core.curve_stations(GRID, [(195.0, 205.2)], 1000.3) == []
|
||||
|
||||
|
||||
def test_MC_옆_관_측점이면_안_더함() -> None:
|
||||
assert core.curve_stations([*GRID, 107.003], [(101.0, 113.6)], 1000.3) == []
|
||||
|
||||
|
||||
def test_노선_밖_MC_는_뺌() -> None:
|
||||
assert core.curve_stations([0.0], [(995.0, 1010.0)], 1000.3) == [] # MC 1003
|
||||
|
||||
|
||||
def test_스냅_끔_MC_마다_곡선_측점() -> None:
|
||||
stations = _server(False)
|
||||
assert _curves(stations) == [107.0, 631.0] # 200 = 일반측점 위 → 안 더함
|
||||
by = {s["chainage_m"]: s for s in stations}
|
||||
assert by[640.0]["kind"] == "regular" and by[200.0]["kind"] == "regular"
|
||||
assert by[107.0]["label"] == "STA.0+107.000 (MC)"
|
||||
assert len(stations) == len(_server(False, extras=())) == 51 + 1 + 2 # 격자 · EP · MC 둘
|
||||
assert 101.0 not in by and 113.6 not in by # BC · EC 안 더함
|
||||
|
||||
|
||||
def test_스냅_켬_짧은_호만_곡선_측점() -> None:
|
||||
stations = _server(True)
|
||||
by = {s["chainage_m"]: s for s in stations}
|
||||
assert _curves(stations) == [107.0]
|
||||
assert by[631.0]["kind"] == "regular" and 640.0 not in by # 224-2 옮김 그대로
|
||||
|
||||
|
||||
def test_스냅_켬_0_1_간격() -> None:
|
||||
stations = _server(True, 0.1)
|
||||
by = {s["chainage_m"]: s for s in stations}
|
||||
assert _curves(stations) == [107.3]
|
||||
assert 631.3 in by and by[200.1]["kind"] == "regular" # 옮긴 일반측점
|
||||
|
||||
|
||||
def test_MC_에_구조물이면_구조물_측점_하나() -> None:
|
||||
stations = _server(False, extras=((107.0, "관"),))
|
||||
by = {s["chainage_m"]: s for s in stations}
|
||||
assert by[107.0]["kind"] == "irregular" and _curves(stations) == [631.0]
|
||||
|
||||
|
||||
def test_열쇠() -> None:
|
||||
assert station_key("curve", 107.0, 20.0) == "curve:107.00"
|
||||
assert station_key("curve", 107.3, 20.0) == "curve:107.30"
|
||||
|
||||
|
||||
# ── 파이썬 ↔ TS 같은 목록 ─────────────────────────────────────────────────────
|
||||
|
||||
CASES = [
|
||||
{"name": "끔", "snap": False, "step": None},
|
||||
{"name": "켬", "snap": True, "step": None},
|
||||
{"name": "켬0.1", "snap": True, "step": 0.1},
|
||||
{"name": "끔0.1", "snap": False, "step": 0.1}, # 끔이면 간격 무시 = 정수
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts_rows() -> list[list[dict]]:
|
||||
job = {
|
||||
"cases": [{"line": LINE, "curves": CURVES, **case} for case in CASES],
|
||||
"pipes": [],
|
||||
"structures": [],
|
||||
"types": [],
|
||||
"move": {
|
||||
"line": LINE,
|
||||
"curves": CURVES,
|
||||
"movedLine": LINE,
|
||||
"movedCurves": CURVES,
|
||||
"fromM": 0.0,
|
||||
},
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / "job.json"
|
||||
path.write_text(json.dumps(job), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_344_3_pipeline_stations.cjs"), str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)["cases"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES])
|
||||
def test_파이썬과_TS_같은_목록(ts_rows, index) -> None:
|
||||
case = CASES[index]
|
||||
server = _server(case["snap"], case["step"])
|
||||
ours = ts_rows[index]
|
||||
assert [s["chainage_m"] for s in server] == pytest.approx(
|
||||
[s["chainageM"] for s in ours], abs=1e-6
|
||||
)
|
||||
for py, ts in zip(server, ours, strict=True):
|
||||
assert (py["kind"] == "curve") == (ts["kind"] == "curve"), py["chainage_m"]
|
||||
if ts["kind"] == "curve":
|
||||
assert ts["key"] == station_key("curve", py["chainage_m"], 20.0)
|
||||
assert any(s["kind"] == "curve" for s in ours)
|
||||
# 곡선 측점은 격자 번호를 안 가져감 — 격자 열쇠는 그대로 1 ~ 50 하나씩(0 = BP)
|
||||
grid = sorted(int(s["key"][5:]) for s in ours if s["kind"] == "grid")
|
||||
assert grid == list(range(1, 51))
|
||||
@@ -0,0 +1,76 @@
|
||||
"""409 B05 평면 구조물 마커 맨 위 · 다른 곳 누르면 고름 풀림(요구 252-4).
|
||||
|
||||
그림 = 맨 마지막(교각점 · 손잡이 · 맴돔 위) · 누름 · 맴돔 · 커서 = 재기 다음 마커 먼저 ·
|
||||
마커 누르면 고른 교각점 풂 ·
|
||||
마커 아닌 곳 누름 · Esc = 공용 고름을 `b05-plan` 으로 비움(오른쪽 횡단 판 그대로).
|
||||
TS 셈은 `helper_399_plan_structure_marks.cjs`(⑥ 고름 풀기).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_409_mark_top_deselect.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).with_name("helper_399_plan_structure_marks.cjs")
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
return (ROOT.joinpath(*parts)).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
def test_풀기_고른_것_없으면_아무_일_없음(got) -> None:
|
||||
assert got["deselectNone"] == [False, 0]
|
||||
|
||||
|
||||
def test_풀기_공용_고름을_b05_plan_으로_비움(got) -> None:
|
||||
assert got["deselectPicked"] == [True, [None, "b05-plan"], None]
|
||||
|
||||
|
||||
def test_Esc_마커_고름만_풂(got) -> None:
|
||||
assert got["escapePicked"] == [[None, "b05-plan"], None, 1] # 관 고름도 · 공용 Esc 막음
|
||||
assert got["escapeNone"] == [0, 1] # 고른 것 없음 = 공용 Esc 몫
|
||||
|
||||
|
||||
def test_마커_누름은_그대로_고름(got) -> None:
|
||||
assert got["pressAfterRelease"] == "A"
|
||||
|
||||
|
||||
def test_누름_마커_먼저_교각점_풂() -> None:
|
||||
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
|
||||
down = pointer[pointer.index("if (marks.press(px, py)) {") :]
|
||||
branch = down[: down.index("marks.deselect();")]
|
||||
assert "params.setPicked(-1);" in branch and "params.syncCurveBar();" in branch
|
||||
assert "canvas.setPointerCapture(event.pointerId);\n return;" in branch
|
||||
assert "else if (marks.press(" not in pointer # 옛 차례(교각점 · 손잡이 다음) 걷힘
|
||||
|
||||
|
||||
def test_맴돔_커서_마커_먼저() -> None:
|
||||
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
|
||||
over = pointer.index("const onMark = !measuring && marks.over(px, py);")
|
||||
pick = pointer.index("const over = measuring || onMark ? null : pickAt(px, py);")
|
||||
cursor = pointer.index("canvas.style.cursor = onMark")
|
||||
assert over < pick < cursor
|
||||
|
||||
|
||||
def test_오른쪽_판은_제_이름_알림을_거름() -> None:
|
||||
cross = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts")
|
||||
assert "if (from !== PLAN_PICK_FROM) follow(selection);" in cross
|
||||
marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts")
|
||||
assert "selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });" in marks
|
||||
@@ -0,0 +1,58 @@
|
||||
"""411-A B05 평면 오른쪽 횡단 판 — 구조물 · 관을 골라도 그 측점 횡단(요구 252-6) ·
|
||||
옮긴 뒤에도 그 측점.
|
||||
|
||||
TS 셈은 `helper_411_a_cross_structure.cjs`(Node · `_RouteEdit_Cross` 진짜 · 묶음 · 그림 가짜).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_411_a_cross_structure.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HELPER = Path(__file__).with_name("helper_411_a_cross_structure.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)
|
||||
|
||||
|
||||
def test_구조물_고름_그_측점_횡단(got) -> None:
|
||||
assert got["structurePick"] == {"station": "45", "foot": "횡단 45", "structure": True}
|
||||
|
||||
|
||||
def test_옮긴_뒤_열쇠로_새_자리(got) -> None:
|
||||
assert got["structureMoved"] == {"station": "52", "foot": "횡단 52", "at": 52}
|
||||
|
||||
|
||||
def test_관_고름_그_측점_횡단(got) -> None:
|
||||
assert got["pipePick"] == {"station": "80.17", "foot": "횡단 80.17", "structure": True}
|
||||
|
||||
|
||||
def test_평면_마커_풂은_판_그대로(got) -> None:
|
||||
assert got["planRelease"] == {"station": "80.17", "foot": "횡단 80.17"}
|
||||
|
||||
|
||||
def test_일반측점_그대로(got) -> None:
|
||||
assert got["gridPick"] == {"station": "20", "foot": "횡단 20", "structure": None}
|
||||
|
||||
|
||||
def test_묶음_전_고름도_열쇠로_붙듦(got) -> None:
|
||||
assert got["beforePipeline"]["foot"] == "계산 중…"
|
||||
assert got["afterPipeline"] == {"station": "60", "foot": "횡단 60"}
|
||||
assert got["latchedMoved"] == {"station": "66", "foot": "횡단 66"}
|
||||
|
||||
|
||||
def test_다른_보기_풂은_판_비움(got) -> None:
|
||||
assert got["otherRelease"]["station"] == "" and got["otherRelease"]["watched"] is None
|
||||
Reference in New Issue
Block a user