Merge remote-tracking branch 'origin/dev' into sub_laptop_7

This commit is contained in:
2026-10-18 09:56:20 +09:00
57 changed files with 1995 additions and 167 deletions
+4
View File
@@ -12,6 +12,10 @@ import { NO_SELECTION, selectDesign } from "./design_selection";
/** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */
export const ESCAPE_FROM = "esc";
/** 평면 노선 편집 창이 낸 고름(눈금 클릭 · 구조물 마커 고름 풂) — 오른쪽 횡단 판은 제 것이라 거름 ·
* 풂(값 없음)이면 Esc 처럼 좌측 폼까지 닫음(409-2). */
export const PLAN_PICK_FROM = "b05-plan";
const OWN_ESCAPE =
"input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop";
+6 -4
View File
@@ -15,7 +15,7 @@ import {
subscribeSelection,
type DesignSelection,
} from "./design_selection";
import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape";
import { bindEscapeRelease, ESCAPE_FROM, PLAN_PICK_FROM } from "./design_pick_escape";
import { pipeIdAt } from "./design_pipe_ids";
import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
@@ -149,10 +149,12 @@ export function bindProfilePick(
if (target.profile.drainage) paintMapPick(target.profile.drainage, selection);
};
host.onDispose(
subscribeSelection(projectId, (_selection, from) => {
subscribeSelection(projectId, (selection, from) => {
paint();
if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection())
target.form.selectById(null);
// 풀기 = Esc · 평면 마커 고름 풂(409-2) — 폼 닫기는 펴지 않음(좌측 패널 접힘 · 펼침 그대로 · 398-1)
const release =
from === ESCAPE_FROM || (from === PLAN_PICK_FROM && selection.chainageM === null);
if (release && host.visible() && target.form?.hasSelection()) target.form.selectById(null);
}),
);
bindEscapeRelease(projectId, host);
@@ -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;
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
+10 -2
View File
@@ -553,14 +553,18 @@ def sample_structure_sections(
extra_stations: tuple[tuple[float, str], ...],
options: SectionGenerationOptions | None = None,
crs: str | None = None,
with_curves: bool = False,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀.
`with_curves` — 곡선 측점(`curve` · 252-2)도 같이(정본 쓰기 · 구조물이 MC 를 비우면
되살림 · 407-3).
격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점
(`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기
(`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2).
"""
if not extra_stations:
if not extra_stations and not with_curves:
return [], []
base = options or SectionGenerationOptions()
merged_options = SectionGenerationOptions(
@@ -587,6 +591,8 @@ def sample_structure_sections(
)
def structural(entry: dict[str, Any]) -> bool:
if with_curves and entry.get("kind") == "curve":
return True
return entry.get("kind") == "irregular" or bool(entry.get("structure"))
return (
@@ -606,7 +612,8 @@ def generate_irregular_sections(
options: SectionGenerationOptions | None = None,
crs: str | None = None,
) -> list[dict[str, Any]]:
"""구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음).
"""구조물 측점(곡선 측점 포함 · 407-3)만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을
돌려준다(빈 목록 = 없음).
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272).
각각 `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
@@ -621,6 +628,7 @@ def generate_irregular_sections(
extra_stations=extra_stations,
options=options,
crs=crs,
with_curves=True,
)
if not irregular_stations:
return []
+90 -26
View File
@@ -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]],
@@ -360,31 +381,17 @@ def _design_curve_widenings(
return radii, sides, widenings
def generate_sections(
polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler,
options: SectionGenerationOptions | None = None,
*,
source_snapshot: dict[str, Any] | None = None,
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
station_snap: bool = False,
station_snap_step: float | None = None,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) ·
`station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.).
"""
options = options or SectionGenerationOptions()
options.validate()
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
def _station_layout(
points: np.ndarray,
route_chainage: np.ndarray,
options: SectionGenerationOptions,
design_curves: list[dict[str, Any]] | None,
station_snap: bool,
station_snap_step: float | None,
) -> tuple[np.ndarray, list, set[float], dict[float, str], set[float]]:
"""측점 자리 한 벌(지반 표본 없음) — (측점 누가거리 · 곡선 구간 · 격자 자리 · 구조물 측점 ·
곡선 측점). `generate_sections` · `planned_curve_stations`(B06 측점 맞춤 판정 · 407-3)가 같은 길."""
total = float(route_chainage[-1])
long_chainage = _chainages(total, options.long_sample_interval_m, True)
long_xy = _interpolate_xy(points, route_chainage, long_chainage)
long_z, long_valid = sampler.sample_xy(long_xy)
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if station_snap:
@@ -413,6 +420,61 @@ 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))])
return station_chainage, spans, regular_rounded, extras, curves
def planned_curve_stations(
polyline: np.ndarray | list[list[float]],
options: SectionGenerationOptions | None = None,
*,
design_curves: list[dict[str, Any]] | None = None,
station_snap: bool = False,
station_snap_step: float | None = None,
) -> list[float]:
"""지금 노선 · 관 · 구조물(`options.extra_stations`)이 세울 곡선 측점 누가거리(오름차순) —
`generate_sections` 와 같은 값 · 표본은 안 뜸."""
options = options or SectionGenerationOptions()
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
layout = _station_layout(
points, route_chainage, options, design_curves, station_snap, station_snap_step
)
return sorted(layout[4])
def generate_sections(
polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler,
options: SectionGenerationOptions | None = None,
*,
source_snapshot: dict[str, Any] | None = None,
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
station_snap: bool = False,
station_snap_step: float | None = None,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
`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()
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
total = float(route_chainage[-1])
long_chainage = _chainages(total, options.long_sample_interval_m, True)
long_xy = _interpolate_xy(points, route_chainage, long_chainage)
long_z, long_valid = sampler.sample_xy(long_xy)
station_chainage, spans, regular_rounded, extras, curves = _station_layout(
points, route_chainage, options, design_curves, station_snap, station_snap_step
)
station_xy = _interpolate_xy(points, route_chainage, station_chainage)
tangents = np.vstack(
[
@@ -477,6 +539,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 +573,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),
+6 -2
View File
@@ -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:
+7 -4
View File
@@ -300,7 +300,8 @@ def _merge_irregular_into_longitudinal(
`irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은
`grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도),
구조물이 떠난 격자 자리(`grid`)는 일반 측점으로 되돌리고, 새 목록의 격자 자리 측점은 그
격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 표고 샘플은 손대지 않는다.
격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 곡선 측점(`curve`)도 새 목록이 통째로
갈음(구조물이 MC 를 비우면 되살아남 · 407-3). 표고 샘플은 손대지 않는다.
B06 상세는 종단 파일의 stations 에 맞는 cross 파일만 노출하고 남은 파일을 지운다
(`prune_stale_cross_files`). 계획선은 사라진 구조물 측점 자리만 고친다(`_follow_moved_stations`
— 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 다시 맞춰도 중복이 생기지 않는다.
@@ -318,14 +319,16 @@ def _merge_irregular_into_longitudinal(
before = [
float(station.get("chainage_m", 0.0))
for station in stations
if station.get("kind") == "irregular"
if station.get("kind") in ("irregular", "curve")
]
fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
kept: dict[float, dict[str, Any]] = {}
left: list[dict[str, Any]] = []
for station in stations:
if station.get("kind") == "irregular" and not station.get("grid"):
continue # 옛 구조물 측점 — 새 목록이 갈음
if station.get("kind") == "curve" or (
station.get("kind") == "irregular" and not station.get("grid")
):
continue # 옛 구조물 측점 · 곡선 측점(407-3) — 새 목록이 갈음
key = round(float(station.get("chainage_m", 0.0)), 6)
if key not in fresh and ("structure" in station or station.get("grid")):
# 구조물이 떠난 격자 · 시종점 자리 — 일반 측점으로(옛 판 「regular + 글」 포함)
+47 -16
View File
@@ -19,6 +19,7 @@ import type {
ProfileAlignment,
} from "./logic/B05_Profile_UI_Profile_Alignment";
import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common";
import { crowdedThickness } from "../B06_Section/logic/B06_Section_Label_Tiers";
import {
irregularStationId,
isPipeStation,
@@ -74,7 +75,7 @@ const CHAR_WIDTH_EM = 0.6;
const CELL_PADDING_PX = 2;
/** 가로 여유가 있을 때 목표로 삼는 글자 크기. 캔버스 최소 폭 산정의 기준이 된다. */
export const TABLE_TARGET_FONT_PX = 12;
/** 곡선 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭(px). */
/** 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭 위 끝(px · 이웃 사이 틈이 더 좁으면 그 틈). */
const CURVE_ROTATED_WIDTH_PX = 16;
/**
* 같은 측점으로 볼 누가거리 차이(m).
@@ -339,17 +340,17 @@ function buildCurveRows(
: curveByChainage.get(key);
// 직선화된 자리는 반경이 없다 — 빈 칸으로 둔다.
const curve = found && !isStraightCurve(found) ? found : undefined;
return { center: centers[index], curve, rotated: false };
return { center: centers[index], curve };
});
const withCurve = curveEntries.filter((entry) => entry.curve);
for (let pair = 1; pair < withCurve.length; pair += 1) {
if (withCurve[pair].center - withCurve[pair - 1].center < options.cellWidth) {
withCurve[pair - 1].rotated = true;
withCurve[pair].rotated = true;
}
}
// 세운 칸 두께 = 이웃 사이 틈에 맞춤(407-5 — 1.6 m 이웃도 겹치지 않게 · 측점 값 행과 같은 규칙)
const thickness = crowdedThickness(
curveEntries.map((entry) => (entry.curve ? entry.center : null)),
options.cellWidth,
CURVE_ROTATED_WIDTH_PX,
);
curveEntries.forEach(({ center, curve, rotated }) => {
curveEntries.forEach(({ center, curve }, entryIndex) => {
const rotated = thickness[entryIndex];
const lengthCell = element("span", "b05-profile-table__curve");
const radiusCell = element("span", "b05-profile-table__curve");
if (curve) {
@@ -370,16 +371,16 @@ function buildCurveRows(
);
radiusCell.append(radiusInput);
lengthCell.append(lengthInput);
if (rotated) {
if (rotated !== null) {
[lengthCell, radiusCell].forEach((cell) => {
cell.classList.add("is-rotated");
cell.style.width = `${CURVE_ROTATED_WIDTH_PX}px`;
cell.style.width = `${rotated}px`;
});
[lengthInput, radiusInput].forEach((input) => {
const byLength = input.value.length
? (rowHeightPx - 6) / (input.value.length * CHAR_WIDTH_EM)
: fontPx;
input.style.fontSize = `${Math.max(7, Math.min(fontPx, CURVE_ROTATED_WIDTH_PX - 4, byLength))}px`;
input.style.fontSize = `${Math.max(6, Math.min(fontPx, rotated - 2, byLength))}px`;
});
}
@@ -585,21 +586,51 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
table.append(
...buildSegmentRows(alignment, x, fontSize, rowHeight, structureChainages, selectedChainageM),
);
buildStationRows(alignment, stationInterval, display).forEach((spec, index) => {
// 이웃 칸과 글이 겹치는 값 칸(1.6 m 이웃 등)만 그 자리에서 세움 — 판정 = 세 행 가운데 가장 긴 글 폭 ·
// 두께 = 이웃 사이 틈(407-5 · 곡선 행과 같은 함수)
const stationSpecs = buildStationRows(alignment, stationInterval, display);
const isStructural = alignment.stations.map((station) =>
isNearChainage(station.chainage_m, structureChainageList),
);
const longest = Math.max(
0,
...stationSpecs.flatMap((spec) =>
alignment.stations.map((_, stationIndex) =>
isStructural[stationIndex] ? 0 : spec.cell(stationIndex).length,
),
),
);
const stationThickness = crowdedThickness(
alignment.stations.map((_, stationIndex) =>
isStructural[stationIndex] ? null : centers[stationIndex],
),
longest * fontSize * CHAR_WIDTH_EM + CELL_PADDING_PX * 2,
CURVE_ROTATED_WIDTH_PX,
);
stationSpecs.forEach((spec, index) => {
const row = createRow(
`${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`,
spec.label,
spec.unit,
);
// 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다.
alignment.stations.forEach((station, stationIndex) => {
alignment.stations.forEach((_, stationIndex) => {
// 구조물 측점 열은 기본값을 비운다 — 격자(빈 셀)만 남기고 값은 선택 오버레이가 맡는다.
const structural = isNearChainage(station.chainage_m, structureChainageList);
const structural = isStructural[stationIndex];
const cell = element(
"span",
`b05-profile-table__cell${structural ? " is-structure" : ""}`,
structural ? "" : spec.cell(stationIndex),
);
const rotated = stationThickness[stationIndex];
if (rotated !== null) {
const text = cell.textContent ?? "";
const byLength = text.length ? (rowHeight - 2) / (text.length * CHAR_WIDTH_EM) : fontSize;
cell.classList.add("is-rotated");
cell.style.width = `${rotated}px`;
cell.style.fontSize = `${Math.max(6, Math.min(fontSize, rotated - 2, byLength))}px`;
cell.title = text;
}
placeCell(row, centers[stationIndex], cell);
});
table.append(row);
+3 -1
View File
@@ -348,7 +348,7 @@ export async function mountRouteEditor(
locked: apexLock,
picked,
stationIntervalM,
stations: stations(),
...snap.ticks(plannedLine.length ? plannedLine : planned, curveInfo),
});
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
syncCurveBar();
@@ -418,6 +418,8 @@ export async function mountRouteEditor(
apexLock,
limitRadiusM,
limitArcM,
line: plannedLine,
snap: stationSnap ? { stepM: snap(), stationM: stationIntervalM } : null,
}),
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
applyEdit: (message) => applyEdit(message),
@@ -2,6 +2,7 @@ import { createButton } from "@ui/ui_template_elements";
import { createSidePanels, type SidePanels } from "@ui/ui_template_side_panels";
import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks";
import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift";
/* =============================================================================
* B05_Profile_UI_RouteEdit_Chrome.ts
@@ -205,6 +206,33 @@ export function createRouteEditChrome(
setInfo(infoCollapsed);
infoToggle.addEventListener("click", () => setInfo(!infoCollapsed));
// 뜬 종단 판(이웃 칸)이 지도 아래를 덮으면 상태 · 범례를 그 위로(408-2) — 판 높이 끌기 · 접기 ·
// 창 분할(판이 다른 창으로 감 = 못 찾음)을 판 크기 관찰로 따라감. 횡단 판이 넓어 아래 줄이 좁으면 잰 값 창을
// 상태 글판 위로 쌓음(408-4 · 횡단 판 · 글판 크기도 관찰).
let watchedProfile: Element | null = null;
const liftInfo = (): void => {
if (!overlay.isConnected) return; // 붙기 전 첫 알림 · 걷힌 뒤 — 판 관찰은 판과 함께 사라짐
const profile =
overlay.closest(".design-page__main")?.querySelector(".b05-route-profile") ?? null;
if (profile && profile !== watchedProfile) lift.observe((watchedProfile = profile));
const map = overlay.getBoundingClientRect();
const top = profile?.isConnected ? profile.getBoundingClientRect().top : null;
overlay.style.setProperty(
"--b05-profile-cover",
`${infoLiftPx(map.bottom, map.height, top)}px`,
);
const sideOpen = parseFloat(overlay.style.getPropertyValue("--b05-side-open")) || 0;
overlay.classList.toggle("is-narrow-bottom", bottomRowNarrow(map.width, sideOpen));
overlay.style.setProperty(
"--b05-info-h",
`${Math.round(info.getBoundingClientRect().height)}px`,
);
};
const lift = new ResizeObserver(liftInfo);
lift.observe(overlay);
lift.observe(info);
lift.observe(overlay.querySelector(".b05-routeedit__side-wrap")!);
const measureCloseButton = createButton({ label: "✕", variant: "ghost" });
measureCloseButton.classList.add("b05-routeedit__measure-close");
measureCloseButton.setAttribute("aria-label", "닫기");
+17 -18
View File
@@ -14,7 +14,8 @@
* 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
*
* 고름 연동(382-2) — 공용 고름 한 칸(`design_selection`) 구독: 일반측점 고름 = 그 측점 횡단 · 평면 법선 ·
* 구조물 고름 = 평면 그 자리 마름모만 · 평면 눈금 클릭 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름).
* 구조물 · 관 고름 = 그 측점 횡단(411-A · 법선은 평면 마커 몫) · 옮겨도 열쇠로 따라감 ·
* 평면 눈금 클릭 · 마커 고름 풂 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름 = 판 그대로).
* 보는 측점 = 측점 열쇠(grid:k) — 묶음 차례마다 열쇠로 다시 짚음(노선을 고쳐도 같은 측점).
* 파란 법선 폭 = 그 측점 발자국 기록(가게 `footprint.records`) 비탈 끝 좌 · 우 · 깎기 여부(색).
* ========================================================================== */
@@ -34,6 +35,7 @@ import {
type DesignSelection,
} from "../A00_Common/design/design_selection";
import { useDesignStore } from "../A00_Common/design/design_store";
import { PLAN_PICK_FROM } from "../A00_Common/design/design_pick_escape";
import { stationSection } from "./logic/B05_Profile_Pipeline_Detail";
import {
buildCrossSvg,
@@ -74,23 +76,20 @@ 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 +246,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 +268,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 +285,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 +317,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 +329,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 +342,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);
@@ -22,6 +22,8 @@ import {
createCurveLabel,
type CurveLabel,
} from "./B05_Profile_UI_RouteEdit_Label";
import { fitNote } from "./logic/B05_Profile_RouteEdit_KeyNudge";
import { offStationMcs } from "./logic/B05_Profile_RouteEdit_StationFit";
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
export interface CurveBarState {
@@ -38,6 +40,10 @@ export interface CurveBarState {
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM: number;
limitArcM: number;
/** 그린 계획노선(곡선 편 정점) — 측점 밖 곡선 MC 셈(408-1). */
line: Vertex[];
/** 측점 스냅 켬이면 맞춤 간격 · 측점 간격(m) · 끔이면 null(맞춤 없음 = 보고 없음). */
snap: { stepM: number; stationM: number } | null;
}
export interface CurveBarParams {
@@ -64,16 +70,16 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
{
onClose: () => params.onUnselect(),
onRadius: (value) => {
const { picked, curveRadius } = params.state();
const { picked, curveRadius, planned } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
const message = value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.";
params.applyEdit(message);
noteShortfall(message, "반지름");
noteResult(message, "반지름", planned);
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
const { picked, nodeInfo, curveArc, curveRadius, planned } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
@@ -83,7 +89,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
const message =
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.";
params.applyEdit(message);
noteShortfall(message, "곡선 길이");
noteResult(message, "곡선 길이", planned);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
@@ -124,20 +130,31 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
);
/** 넣은 값을 다 못 그렸으면 상태줄 글을 넣은 값 → 그린 값 · 까닭으로(398-2 · 상태줄 두 줄 안) — 접선은
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다. */
function noteShortfall(message: string, name: "반지름" | "곡선 길이"): void {
const { picked, curveRadius, curveInfo, nodeInfo } = params.state();
const asked = curveRadius[picked];
const drawn = curveInfo.find((entry) => entry.node_first === picked);
if (asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05) return;
const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1;
const m = (value: number): string => `${Math.round(value * 10) / 10}m`;
* 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다.
* 스냅 켬이면 맞춤이 민 교점 · 못 맞춘 곡선을 덧붙임(408-1 · `before` = 입력 전 교점). */
function noteResult(message: string, name: "반지름" | "곡선 길이", before: Vertex[]): void {
const { picked, curveRadius, curveInfo, nodeInfo, planned, line, snap } = params.state();
const status = params.container.querySelector<HTMLElement>(".b05-routeedit__status");
if (!status) return;
status.textContent = (status.textContent ?? "").replace(
message,
`${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`,
);
const asked = curveRadius[picked];
const drawn = curveInfo.find((entry) => entry.node_first === picked);
const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1;
const m = (value: number): string => `${Math.round(value * 10) / 10}m`;
const head =
asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05
? message.replace(/\.$/, "")
: `${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`;
const tail = snap
? fitNote(
before,
planned,
picked,
offStationMcs(line, curveInfo, snap.stepM),
snap.stationM,
snap.stepM,
)
: "";
status.textContent = (status.textContent ?? "").replace(message, `${head}${tail}.`);
}
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
@@ -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,20 @@
* 고른 마커 = 알약 벌룬과 같은 창(`balloonElement` · `balloonLines`) + 그 측점 좌우 20 m 법선(391).
* · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 ·
* 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본).
* · 집기 차례 = 교각점 · 곡선 손잡이 다음(381-3) — `_Pointer` 가 부름.
* · 그림 · 집기 차례 = 맨 위 · 맨 앞(409 · 재기 다음) — `_Render` · `_Pointer` 가 부름.
* · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로 ·
* 좌측 목록 강조 · 폼은 풂 409-2).
* 목록 · 고름 · 옮김은 종단 보기가 `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 +39,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 "../A00_Common/design/design_pick_escape";
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 +187,7 @@ function paintMark(
context.restore();
}
/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 라벨 뒤 · 교각점 아래에 부름. */
/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 맨 마지막에 부름(409). */
export function drawStructureMarks(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
@@ -250,7 +258,7 @@ function showBalloon(
}
export interface StructureMarkInput {
/** 마커를 눌렀으면 고르고 끌기 시작 · true(교각점 · 손잡이 다음 차례). */
/** 마커를 눌렀으면 고르고 끌기 시작 · true(재기 다음 맨 앞 · 409). */
press: (px: number, py: number) => boolean;
/** 끄는 중이면 자리 바꿈 · true. */
move: (px: number, py: number) => boolean;
@@ -258,6 +266,8 @@ export interface StructureMarkInput {
release: () => boolean;
/** 마커 위인가 — 커서 몫. */
over: (px: number, py: number) => boolean;
/** 고른 마커가 있으면 풂 · true(409 — 마커 아닌 곳 누름 · Esc). */
deselect: () => boolean;
}
/** 끌기 · 방향키 배선 — `_Pointer` 가 부름(같은 재료 `params`). */
@@ -321,6 +331,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 +369,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 +422,6 @@ export function bindStructureMarks(
},
release: () => !holdKey && finish(),
over: (px, py) => markAt(px, py) !== null,
deselect,
};
}
+23 -8
View File
@@ -5,6 +5,9 @@
(`.b05-route__view-controls` · ☰ 비킴 `--wf-burger-pad`) · 횡단 판은 오른쪽을 덮는 판. */
.b05-routeedit {
--b05-side-open: 0px; /* 횡단 판이 지도를 덮는 폭(손잡이 포함 · `_Chrome` 이 적음) */
/* 아래 줄 반 폭(408-4) — 횡단 판 왼쪽 빈 폭을 상태 글판 · 잰 값 창이 반씩(안 겹침 · 좁으면 줄바꿈) ·
% 는 쓰는 자리(지도 칸) 기준 · 반이 모자라면(`is-narrow-bottom` · `_Chrome`) 빈 폭 전부 · 위아래로 쌓음. */
--b05-bottom-half: max(0px, calc((100% - 3 * var(--spacing-12) - var(--b05-side-open)) / 2));
position: relative;
display: flex;
flex: 1 1 auto;
@@ -88,16 +91,16 @@
display: grid;
}
/* 상태 · 범례 — 지도 왼쪽 아래(사용자 지시 ⑫) · 뜬 종단 판이 덮으면 가려진 채(349-0).
/* 상태 · 범례 — 지도 왼쪽 아래(사용자 지시 ⑫) · 뜬 종단 판이 덮으면 그 위로(408-2 · `_Chrome` 이 적음).
두 줄 안 · 좁은 폭(345-5 덧 3) — 왼쪽 상태 글(두 줄에서 자름) · 가운데 범례 두 줄 · 오른쪽 ▾. */
.b05-routeedit__info {
bottom: var(--spacing-12);
bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));
left: var(--spacing-12);
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: start;
gap: 0 var(--spacing-12);
max-width: min(460px, 46%);
max-width: min(460px, 46%, var(--b05-bottom-half));
padding: var(--spacing-4, 4px) var(--spacing-8);
line-height: 1.45;
}
@@ -184,13 +187,14 @@
}
/* 오른쪽 덮는 판 — 패널 양식(382-1 · `ui_template_side_panels` · 손잡이 · 머리 · 폭 끌기) 을 지도 위에 얹음 ·
위아래 반씩 **지금 횡단** · **이전 횡단**(352-1) · 아래 종단 판(z 3)이 그 위를 덮음 · 빈 자리는 지도 조작을 통과. */
위아래 반씩 **지금 횡단** · **이전 횡단**(352-1) · 뜬 종단 판이 덮으면 그 위까지(408-3 · 손잡이 · 아래 반 보임) ·
빈 자리는 지도 조작을 통과. */
.b05-routeedit__side-wrap {
position: absolute;
z-index: 2;
top: calc(var(--spacing-16) + 34px + var(--spacing-8)); /* 도구줄 · 회전 단추 줄 아래 */
right: var(--spacing-12);
bottom: var(--spacing-12);
bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));
display: flex;
min-height: 0;
pointer-events: none;
@@ -364,16 +368,16 @@
opacity: 0.5;
}
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 가려진 채 · 349-0). 닫으면 잰 것이 지워진다. */
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 그 위로 · 408-3). 닫으면 잰 것이 지워진다. */
.b05-routeedit__measure {
position: absolute;
right: calc(var(--spacing-12) + var(--b05-side-open));
bottom: var(--spacing-12);
bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));
z-index: 1;
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
max-width: 52%;
max-width: var(--b05-bottom-half);
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid color-mix(in srgb, #22c55e 60%, transparent);
border-radius: var(--radius-8, 6px);
@@ -384,6 +388,17 @@
font-size: var(--text-caption);
}
/* 아래 줄 좁음(408-4) — 잰 값 창은 상태 글판 위에 · 둘 다 횡단 판 왼쪽 빈 폭 전부. */
.b05-routeedit.is-narrow-bottom {
--b05-bottom-half: max(0px, calc(100% - 2 * var(--spacing-12) - var(--b05-side-open)));
}
.b05-routeedit.is-narrow-bottom .b05-routeedit__measure {
bottom: calc(
var(--spacing-12) + var(--b05-profile-cover, 0px) + var(--b05-info-h, 0px) + var(--spacing-8)
);
}
/* 글이 길면 접힌다 — flex 자식은 기본으로 안 줄어들어 왼쪽으로 넘쳐 잘렸다(2026-09-12). */
.b05-routeedit__measure-text {
min-width: 0;
@@ -84,6 +84,12 @@
/* 이웃 곡선 셀과 겹치는 좁은 자리(배관 측점 등) — 셀을 얇게 줄이고 입력을 세로쓰기로
세운다. 자리는 측점 수직선 그대로다(옆으로 비키면 다른 열 값으로 오해, 2026-08-04). */
/* 측점 값 칸도 같은 규칙(407-5) — 이웃과 겹치면 칸을 얇게 · 글을 세움 · 지우지 않음. */
.b05-profile-table__cell.is-rotated {
writing-mode: vertical-rl;
white-space: nowrap;
}
.b05-profile-table__curve.is-rotated input {
writing-mode: vertical-rl;
width: 100%;
@@ -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 {
@@ -0,0 +1,30 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts
* 평면 상태 · 범례 올림(408-2) — 뜬 종단 판이 지도 아래를 덮은 높이만큼 위로.
* 아래 줄 좁음(408-4) — 횡단 판 왼쪽 빈 폭의 반이 모자라면 잰 값 창을 상태 글판 위로 쌓음.
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Chrome`.
* ========================================================================== */
/** 상태 글판이 이 높이(px)는 지도 위에 남게 — 판을 끝까지 올려도 지도 밖으로 안 나감. */
const KEEP_PX = 120;
/** 올릴 높이(px) — `profileTop` = 종단 판 윗변(없음 · 접힘 · 창 분할 = null) · 지도 칸 아래 · 높이. */
export function infoLiftPx(
mapBottom: number,
mapHeight: number,
profileTop: number | null,
): number {
if (profileTop === null) return 0;
const cover = Math.max(0, mapBottom - profileTop);
return Math.round(Math.min(cover, Math.max(0, mapHeight - KEEP_PX)));
}
/** 아래 줄 반 폭이 이보다 좁으면 나란히 두지 않고 쌓음(px). */
const HALF_MIN_PX = 200;
/** 지도 칸 가장자리 · 두 글판 사이 · 횡단 판 앞 간격(px) — CSS `--spacing-12`. */
const GAP_PX = 12;
/** 상태 글판 · 잰 값 창을 나란히 못 두나 — `mapWidth` = 지도 칸 폭 · `sideOpen` = 횡단 판이 덮는 폭. */
export function bottomRowNarrow(mapWidth: number, sideOpen: number): boolean {
return (mapWidth - 3 * GAP_PX - sideOpen) / 2 < HALF_MIN_PX;
}
@@ -5,9 +5,11 @@
* · 한 번 0.1 m · Shift ×10 · 위 = 화면 위(지도를 돌려도 화면 기준).
* · 측점 스냅 켬이면 한 번 = 스냅 간격(0.1 · 1 · 10 m · 측점 No. · Shift 안 씀 · 390-5) · 걸음마다
* MC 를 그 간격 배수에 맞춘 자리로 보임 · 뗄 때 정본 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
* · 반지름 · 곡선 길이 입력 뒤(408-1)도 같은 셈으로 맞춤이 민 교점 · 못 맞춘 곡선을 알림(`fitNote`).
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Keys`.
* ========================================================================== */
import { stationLabel } from "../../common_util/common_util_station";
import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
/** 방향키 한 번(m) · Shift 배수. */
@@ -94,3 +96,22 @@ export function nudgeMessage(report: NudgeReport, snapping: boolean): string {
if (report.movedM < STAY_M) return "같은 측점 — 스냅 끄고 미세 조정하세요.";
return `측점에 맞춰 실제 이동 ${report.movedM.toFixed(1)} m · 함께 밀린 IP ${report.pushed}곳.`;
}
/** 반지름 · 곡선 길이 입력 뒤 맞춤 결과 꼬리(408-1) — 손으로 옮긴 교점이 없으니 고른 IP 도 「밀림」에 셈 ·
* 못 맞춘 곡선 = 측점 밖 MC(`offStationMcs`)를 측점 표기로(셋까지 · 나머지 「외 N」). */
export function fitNote(
before: ReadonlyArray<Vertex>,
after: ReadonlyArray<Vertex>,
picked: number,
offMcs: ReadonlyArray<number>,
stationM: number,
stepM: number,
): string {
const start = before[picked] ?? [0, 0];
const report = nudgeReport(before, after, picked, start, start);
const pushed = report.pushed + (report.movedM > SAME_M ? 1 : 0);
const names = offMcs.map((mc) => `No.${stationLabel(mc, stationM, stepM < 1 ? 1 : 0)}`);
const more = names.length > 3 ? ` 외 ${names.length - 3}` : "";
const off = names.length ? ` · 못 맞춘 곡선 ${names.slice(0, 3).join(", ")}${more}` : "";
return ` · 함께 밀린 교점 ${pushed}곳${off}`;
}
@@ -66,15 +66,25 @@ export function offStationCount(
curves: ReadonlyArray<EditedCurve>,
intervalM: number,
): number {
if (!(intervalM > 0)) return 0;
const spans = curveSpans(line, curves);
return offStationMcs(line, curves, intervalM).length;
}
/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정. */
export function offStationMcs(
line: ReadonlyArray<Vertex>,
curves: ReadonlyArray<EditedCurve>,
intervalM: number,
): number[] {
if (!(intervalM > 0)) return [];
const hits = new Set<number>();
for (const [start, end] of spans) {
const off: number[] = [];
for (const [start, end] of curveSpans(line, curves)) {
const mc = (start + end) / 2;
const n = Math.round(mc / intervalM);
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM)) hits.add(n);
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM) && !hits.has(n)) hits.add(n);
else off.push(mc);
}
return spans.length - hits.size;
return off;
}
/** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */
@@ -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);
}
+2 -2
View File
@@ -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) — 구조물이 떠나면 일반 측점. */
@@ -156,6 +156,47 @@ def _stale_marks(project_root: Path, route_data_path: str) -> int:
return len(standing ^ planted)
def _curve_marks(project_root: Path, route_data_path: str, options: Any) -> int:
"""종단 정본의 곡선 측점(`curve`)과 **지금 노선 · 관 · 구조물**이 세울 곡선 측점이 어긋난 수
(407-3 — 구조물을 MC 위에 놓거나 빼면 바뀜 · 화면 초안 `buildStations` 와 같은 목록).
종단 정본이 없으면 0."""
import json
from dataclasses import replace
from B05_Profile.B05_Profile_Engine_Sections import (
_load_design_curves,
_load_pipe_points,
_load_route_polyline,
load_station_snap,
load_station_snap_step,
resolve_extra_stations,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import planned_curve_stations
files = sorted((project_root / "B06_Section" / "longitudinal").glob("*.json"))
if not files:
return 0
standing: set[float] = set()
for path in files:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
for station in data.get("stations") or []:
if station.get("kind") == "curve":
standing.add(round(float(station.get("chainage_m") or 0.0), 2))
polyline = _load_route_polyline(project_root, route_data_path)
extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
planned = planned_curve_stations(
polyline,
replace(options, extra_stations=extras),
design_curves=_load_design_curves(project_root),
station_snap=load_station_snap(project_root),
station_snap_step=load_station_snap_step(project_root),
)
return len({round(value, 2) for value in planned} ^ standing)
async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None:
async def _load(connection):
stored = await get_project_storage_relative_path(connection, project_id)
@@ -250,6 +291,7 @@ async def _regenerate_structure_stations(
async def ensure_structure_stations(project_id: UUID) -> int:
"""빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎).
곡선 측점이 지금 관 · 구조물과 어긋나도 맞춤(407-3 · [저장] 의 관 · 구조물 차례 뒤).
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리
횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다.
@@ -262,6 +304,17 @@ async def ensure_structure_stations(project_id: UUID) -> int:
missing = await asyncio.to_thread(_missing_marks, project_root, str(route["route_data_path"]))
# 빠진 것만이 아니라 낡은 것(옮긴 관 옛 자리 · 떠난 격자 자리)도 맞춤 — 두 벌로 남지 않게(272)
stale = await asyncio.to_thread(_stale_marks, project_root, str(route["route_data_path"]))
# 곡선 측점도 지금 관 · 구조물로 — MC 위 구조물을 빼면 되살아남(노선 저장 안 기다림 · 407-3)
from B05_Profile.B05_Profile_Router_Confirm import _section_options_from_stored
from B06_Section.B06_Section_Repository import get_latest_section_options
stored = await run_with_connection(get_latest_section_options, project_id)
stale += await asyncio.to_thread(
_curve_marks,
project_root,
str(route["route_data_path"]),
_section_options_from_stored(stored),
)
if not missing and not stale:
return 0
snapshot = await _sampling_conditions(project_id, project_root)
@@ -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);
+43 -11
View File
@@ -7,6 +7,7 @@
* ========================================================================== */
import type { DesignProfile, LongitudinalSection } from "./B06_Section_Api_Fetch";
import { labelTiers, labelWidthPx } from "./logic/B06_Section_Label_Tiers";
import {
emptyView,
inferStationInterval,
@@ -189,6 +190,8 @@ function attachStationDrag(
/** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */
const STATION_DRAG_SLOP_PX = 3;
/** 겹친 이름표 한 단 높이(px · 10px 글 + 틈 · B05 종단은 한 단 올리면 축선 바로 위). */
const LABEL_TIER_PX = 13;
export function createLongitudinalProfile(
data: LongitudinalSection,
@@ -392,9 +395,38 @@ export function createLongitudinalProfile(
// 구조물 이름은 **맨 마지막에** 붙인다 — 측점 마커 안에 넣으면 뒤이어 그리는 지반선·
// 계획선이 그 위를 덮는다(2026-08-02 사용자 지시: 오버레이 순서 최상위).
const structureLabels: SVGTextElement[] = [];
for (const station of data.stations) {
// 가까운 측점끼리 이름이 겹치면 위 단으로 비킴(407-4) — 구조물 이름은 올라간 측점 이름 위로.
const stationTexts = data.stations.map(
(station) =>
offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset) +
(station.kind === "curve" ? " (MC)" : ""),
);
const stationTiers = labelTiers(
data.stations.map((station, index) => ({
x: x(station.chainage_m),
width: labelWidthPx(stationTexts[index], 10),
})),
);
const named = data.stations.filter((station) => station.structure);
const namedTiers = labelTiers(
named.map((station) => ({
x: x(station.chainage_m),
width: labelWidthPx(structureDisplayName(station.structure!), 9),
})),
);
const structureLift = LABEL_TIER_PX * Math.max(0, ...stationTiers);
data.stations.forEach((station, stationIndex) => {
const stationX = x(station.chainage_m);
const selected = station.station_id === selectedStationId;
const tier = stationTiers[stationIndex];
// 올라간 이름 = 선 · 지반선 위에 얹음(맨 끝 겹 · 바탕색 테두리)
const label = svgText(stationTexts[stationIndex], {
x: stationX,
y: heightPx - bottomInsetPx - 5 - LABEL_TIER_PX * tier,
"text-anchor": "middle",
class: `b06-chart__station-label${tier ? " b06-chart__station-label--lifted" : ""}${tier && selected ? " b06-chart__station-label--selected" : ""}`,
});
if (tier) structureLabels.push(label);
const marker = svgElement("g", {
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
// B05 [직선화]·[쉬프트] 도구가 클릭한 측점의 누가거리를 여기서 읽는다(2026-09-02).
@@ -426,15 +458,10 @@ export function createLongitudinalProfile(
y2: heightPx - padBottom,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.plus ? "plus" : station.kind}`,
}),
// 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점
// 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시).
svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), {
x: stationX,
y: heightPx - bottomInsetPx - 5,
"text-anchor": "middle",
class: "b06-chart__station-label",
}),
);
// 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점
// 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시).
if (!tier) marker.append(label);
// 구조물(비정규) 측점: 구조물 이름으로 위치를 식별할 수 있게 한다.
// 플롯 **하단**(측점 라벨 바로 위)에 둔다 — 상단은 그래프 이름표와 절·성토 표기가
// 이미 쓰고 있어 서로 겹쳤다(2026-08-02 사용자 지시).
@@ -443,14 +470,19 @@ export function createLongitudinalProfile(
// 배수관은 관종·관경 대신 표시 이름 하나로 적는다(2026-09-03 — 횡단 카드와 같은 규칙).
svgText(structureDisplayName(station.structure), {
x: stationX,
y: heightPx - padBottom - 5,
y:
heightPx -
padBottom -
5 -
structureLift -
LABEL_TIER_PX * namedTiers[named.indexOf(station)],
"text-anchor": "middle",
class: "b06-chart__structure-label",
}),
);
}
svg.append(marker);
}
});
const points = samples
.map((sample) => {
+13 -2
View File
@@ -408,6 +408,15 @@ html.is-bm-route .b06-section__notice-bar {
font-weight: var(--font-weight-medium);
}
/* 겹쳐 위 단으로 올린 측점 이름(407-4) — 선 · 지반선 위라 바탕색 테두리로 읽히게. */
.b06-chart__station-label--lifted {
paint-order: stroke;
stroke: var(--color-surface-raised);
stroke-width: 3px;
stroke-linejoin: round;
pointer-events: none;
}
/* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */
.b06-chart__structure-label {
font-size: 9px;
@@ -482,7 +491,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);
}
@@ -516,7 +526,8 @@ html.is-bm-route .b06-section__notice-bar {
stroke: var(--color-royal-amethyst);
}
.b06-chart__station--selected .b06-chart__station-label {
.b06-chart__station--selected .b06-chart__station-label,
.b06-chart__station-label--selected {
fill: var(--color-danger);
font-weight: var(--font-weight-bold);
}
@@ -0,0 +1,55 @@
/* =============================================================================
* B06_Section/logic/B06_Section_Label_Tiers.ts
* 종단 측점 · 구조물 이름표 비켜 그리기(PLAN 407-4) — 가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단으로
* 올림 · 이름은 지우지 않음. 그리는 곳 = `B06_Section_UI_Longitudinal`(B05 종단도 같은 함수).
* B05 종단 아래 표 겹친 칸 세우기(407-5 · `crowdedThickness` · `B05_Profile_UI_Profile_Table`).
* 시험 `resources/tester/test_407_4_label_tiers.py`. 화면 · 세션 없음(순수).
* ========================================================================== */
/** 단 수 위 끝 — 넘으면 마지막 단에 겹쳐 둠(이름은 남김). */
export const MAX_LABEL_TIER = 3;
/** 글 폭 짐작(px) — 한글 · 한자 = 글자 크기 · 그 밖 = 0.6 배. */
export const labelWidthPx = (text: string, fontPx: number): number =>
[...text].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 0x2e80 ? fontPx : fontPx * 0.6), 0);
/** 가운데 맞춤 이름표마다 단(0 = 제자리) — 왼쪽부터 겹치지 않는 가장 낮은 단 · `gapPx` = 글 사이 틈. */
export function labelTiers(
items: ReadonlyArray<{ x: number; width: number }>,
gapPx = 3,
): number[] {
const order = items.map((_, index) => index).sort((a, b) => items[a].x - items[b].x);
const rightEdge: number[] = []; // 단마다 마지막 이름표 오른쪽 끝
const tiers = items.map(() => 0);
for (const index of order) {
const { x, width } = items[index];
let tier = rightEdge.findIndex((edge) => edge + gapPx <= x - width / 2);
if (tier < 0) tier = Math.min(rightEdge.length, MAX_LABEL_TIER);
rightEdge[tier] = Math.max(rightEdge[tier] ?? -Infinity, x + width / 2);
tiers[index] = tier;
}
return tiers;
}
/** 겹친 칸 세우기(407-5 · B05 종단 아래 표) — 같은 폭(`width`) 칸이 이웃과 가로로 겹치면 세운 칸 두께
* (px · 가까운 이웃까지 거리 − 1 · `minPx` ~ `maxPx`) · 안 겹치면 null(그대로) · x 가 null = 빈 칸(판정에서 뺌). */
export function crowdedThickness(
xs: ReadonlyArray<number | null>,
width: number,
maxPx: number,
minPx = 6,
): Array<number | null> {
const order = xs
.map((x, index) => ({ x, index }))
.filter((item): item is { x: number; index: number } => item.x !== null)
.sort((a, b) => a.x - b.x);
const out: Array<number | null> = xs.map(() => null);
order.forEach(({ x, index }, k) => {
const nearest = Math.min(
k > 0 ? x - order[k - 1].x : Infinity,
k < order.length - 1 ? order[k + 1].x - x : Infinity,
);
if (nearest < width) out[index] = Math.max(minPx, Math.min(maxPx, Math.floor(nearest - 1)));
});
return out;
}
@@ -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";
}
+4 -1
View File
@@ -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":
+4 -2
View File
@@ -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 }),
@@ -69,6 +69,8 @@ function editor(snap) {
apexLock: Array(n).fill(false),
limitRadiusM: LIMITS.limitRadiusM,
limitArcM: LIMITS.limitArcM,
line: [],
snap: snap ? { stepM: LIMITS.intervalM, stationM: LIMITS.intervalM } : null,
};
const status = { textContent: "" };
const markEdited = () => {
@@ -139,11 +141,14 @@ for (const snap of [false, true]) {
ed.pick(1);
h.onRadius(40);
row.r40 = round(ed.drawn(1).radius_m);
const later = () => [2, 3].map((k) => round(ed.mc(k))); // 뒤쪽 곡선 MC(408-1)
row.r40Later = later();
h.onRadius(60);
row.r60 = round(ed.drawn(1).radius_m);
row.r60Shown = round(ed.lastShow().radiusShown);
row.r60Status = ed.status.textContent;
row.r60Mc = round(ed.mc(1));
row.r60Later = later();
h.onArcLength(18);
row.l18 = round(ed.arcOf(1));
h.onArcLength(25);
@@ -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;
},
},
"../A00_Common/design/design_pick_escape": { 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,35 @@
/* 408-2 평면 상태줄 올림 — `logic/B05_Profile_RouteEdit_InfoLift.ts` 를 Node 로 돌려 JSON 한 줄
* (`test_408_2_status_lift.py` 가 판정).
* node helper_408_2_status_lift.cjs
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(
__dirname,
"..",
"..",
"B05_Profile",
"logic",
"B05_Profile_RouteEdit_InfoLift.ts",
);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const { infoLiftPx, bottomRowNarrow } = box.exports;
// 화면 1078 px · 지도 칸 0~1078
process.stdout.write(
JSON.stringify({
open: infoLiftPx(1078, 1078, 430), // 종단 판 열림(648 px)
collapsed: infoLiftPx(1078, 1078, 1077), // 접힘(테두리 1 px)
popout: infoLiftPx(1078, 1078, null), // 창 분할 = 판 없음
below: infoLiftPx(600, 600, 700), // 횡단 보기 = 판이 지도 아래 흐름
tall: infoLiftPx(1078, 1078, 20), // 판을 끝까지 올림 = 지도 안에 남김
// 408-4 지도 칸 1172 px · 횡단 판 접힘(손잡이 32) · 460 열림 · 891 넓힘 — 반 폭 200 아래면 쌓음
narrow: [32, 492, 891, 736, 737].map((side) => bottomRowNarrow(1172, side)),
}),
);
@@ -0,0 +1,161 @@
/* 408-1 R 바꿈 뒤 뒤쪽 측점 맞춤(요구 252-3) — `_CurveBar` 배선을 Node 로 돌려 JSON 한 줄
* (`test_408_radius_downstream.py` 가 판정). 창(`_Label`)은 손잡이만 잡는 가짜 · 편집 창 `applyEdit` 는
* 같은 길(스냅 켬 = `fitStart` → `fitCurveStations` → 길이 고정 R · 하한 → `buildEditedPolyline`)로 흉내.
* node helper_408_radius_downstream.cjs
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const B05 = path.join(ROOT, "B05_Profile");
const cache = new Map();
let handlers = null;
const STUBS = {
"./B05_Profile_UI_RouteEdit_Label": {
createCurveLabel: (given) => {
handlers = given;
return { show: () => {}, hide: () => {}, destroy: () => {} };
},
centerDirectionOf: () => null,
},
};
function loadTs(file) {
const full = path.resolve(file);
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) =>
STUBS[request] ??
(request.startsWith(".")
? loadTs(path.join(path.dirname(full), `${request}.ts`))
: require(request));
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts"));
const edits = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Edits.ts"));
const frame = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Frame.ts"));
const fit = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StationFit.ts"));
const bar = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_CurveBar.ts"));
// 곡선 넷(IP1~4) · 직선이 길어 맞춤이 접선 절반에 안 막힘
const ROUTE = [
[0, 0],
[110, 0],
[210, 60],
[320, 60],
[420, -10],
[540, -10],
];
const LIMITS = { minRadiusM: 12, limitRadiusM: 12, limitArcM: 5, intervalM: 20 };
const KEYS = ["planned", "curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"];
const copy = (state) =>
Object.fromEntries(
KEYS.map((key) => [key, state[key].map((v) => (Array.isArray(v) ? [...v] : v))]),
);
function editor(snap, apexLock = []) {
const n = ROUTE.length;
const state = {
picked: -1,
planned: ROUTE.map((v) => [v[0], v[1]]),
nodeInfo: [],
curveInfo: [],
curveOn: Array(n).fill(true),
curveRadius: Array(n).fill(30),
curveLock: Array(n).fill(null),
curveArc: Array(n).fill(null),
apexLock: Array.from({ length: n }, (_, i) => apexLock.includes(i)),
limitRadiusM: LIMITS.limitRadiusM,
limitArcM: LIMITS.limitArcM,
line: [],
snap: snap ? { stepM: LIMITS.intervalM, stationM: 20 } : null,
};
const status = { textContent: "" };
const markEdited = () => {
edits.applyArcLocks(state.planned, state.curveLock, state.curveArc, state.curveRadius);
edits.applyCurveLimits(
state.planned,
state.curveOn,
state.curveRadius,
LIMITS.limitRadiusM,
LIMITS.limitArcM,
);
const built = curve.buildEditedPolyline(
state.planned,
state.curveOn,
state.curveRadius,
LIMITS.minRadiusM,
);
state.line = built.vertices;
state.curveInfo = built.curves;
state.nodeInfo = built.nodes;
};
let committed = null;
const applyEdit = (message) => {
const from = fit.fitStart(committed, state);
if (snap) state.planned = fit.fitCurveStations(state, LIMITS, from);
markEdited();
status.textContent = `예상노선 — ${message} 꼬리`;
committed = copy(state);
};
bar.createCurveBar({
canvas: { getBoundingClientRect: () => ({ left: 0, top: 0, right: 800, bottom: 600 }) },
container: { querySelector: (sel) => (sel === ".b05-routeedit__status" ? status : null) },
state: () => state,
toScreen: (v) => [v[0], v[1]],
applyEdit,
onUnselect: () => {},
});
// 처음 = 스냅 켤 때처럼 전체 맞춤 한 걸음
markEdited();
applyEdit("측점 스냅을 켰습니다.");
const mcs = () =>
frame
.curveSpans(state.line, state.curveInfo)
.map(([a, b]) => Math.round(((a + b) / 2) * 1000) / 1000);
const off = () => fit.offStationCount(state.line, state.curveInfo, LIMITS.intervalM);
const pick = (k) => {
state.picked = k;
};
return { state, status, h: () => handlers, mcs, off, pick };
}
const out = {};
const row = (ed) => ({
mcs: ed.mcs(),
off: ed.off(),
status: ed.status.textContent,
planned: ed.state.planned.map((v) => [...v]),
});
for (const snap of [true, false]) {
const key = snap ? "on" : "off";
const ed = editor(snap);
out[`${key}Start`] = row(ed);
ed.pick(1);
ed.h().onRadius(60); // 앞쪽 곡선 R 키움
out[`${key}R60`] = row(ed);
ed.h().onArcLength(50); // 곡선 길이 입력도
out[`${key}L50`] = row(ed);
ed.h().onRadius(null); // 빈 값 = 자동
out[`${key}Auto`] = row(ed);
}
// IP3 고정 = 그 곡선은 못 맞춤 → 상태줄 「못 맞춘 곡선 No.…」
{
const ed = editor(true, [3]);
out.lockStart = row(ed);
ed.pick(1);
ed.h().onRadius(60);
out.lockR60 = row(ed);
}
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,102 @@
/* 409-2 평면 마커 고름 풂(b05-plan · 값 없음) = 좌측 폼 · 목록 강조도 풂 — 진짜 `design_selection` ·
* `design_pick_escape` · `design_pick_profile` 을 Node 로 돌림. 종단도 · 폼 · 오른쪽 횡단 판은 흉내.
* 표준출력 JSON 한 벌 · 짝 = test_409_mark_top_deselect.py.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
function loadTs(file, stubs = {}) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request}`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
globalThis.window = { addEventListener() {}, removeEventListener() {} };
globalThis.document = { querySelector: () => null };
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
},
});
const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel });
const IRR = { irregularStationId: (id) => `irregular:${id}`, isPipeStation: () => true };
const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
"../A00_Common/design/design_selection": DSel,
"./B05_Profile_UI_IrregularStations": IRR,
});
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
"./design_selection": DSel,
"./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` },
"./design_pick_escape": ESC,
"../../B05_Profile/B05_Profile_UI_Selection": SY,
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
});
const PID = "p409";
const sel = (chainageM, structureId, from) =>
DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }, {
from,
});
/* 폼 — 관 폼을 닫으면 진짜 폼처럼 관 고름도 비움(`resetForm` → onPipeSelect(null) → b05 풂) */
const form = { open: null, resets: 0 };
const profile = { structure: null };
PP.bindProfilePick(
PID,
{
profile: {
setSelectedStation: () => (profile.structure = null),
setSelectedStructure: (id) => (profile.structure = id),
},
form: {
hasSelection: () => form.open !== null,
selectById: () => {
const wasPipe = form.open === "pipe";
form.open = null;
form.resets += 1;
if (wasPipe) sel(null, null, "b05");
},
},
},
{ visible: () => true, onDispose() {} },
{ irregular: () => [], pipes: () => [{ chainage_m: 80.17 }], sections: () => [] },
);
/* 오른쪽 횡단 판 흉내 — 받은 알림(낸 쪽) */
const crossSaw = [];
DSel.subscribeSelection(PID, (selection, from) => crossSaw.push([selection.chainageM, from]));
const out = {};
// 관 마커 고름 → 폼 열림 → 평면 마커 고름 풂
sel(80.17, null, "b05");
form.open = "pipe";
crossSaw.length = 0;
sel(null, null, ESC.PLAN_PICK_FROM);
out.pipeRelease = { form: { ...form }, crossSaw: [...crossSaw], session: DSel.readSelection(PID) };
// 구조물 고름 → 폼 → 풂
sel(40, "A", "b05");
form.open = "A";
sel(null, null, ESC.PLAN_PICK_FROM);
out.structureRelease = { form: { ...form }, pill: profile.structure };
// 평면 눈금 클릭(값 있음 · b05-plan) = 폼 안 닫음(옛 그대로)
form.open = "A";
sel(20, null, ESC.PLAN_PICK_FROM);
out.tickKeepsForm = form.open;
// 폼 없음 = 안 부름
form.open = null;
const resets = form.resets;
sel(null, null, ESC.PLAN_PICK_FROM);
out.noFormNoReset = form.resets - resets;
out.planName = ESC.PLAN_PICK_FROM;
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,160 @@
/* 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_pick_escape": { PLAN_PICK_FROM: "b05-plan" },
"../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));
+9 -1
View File
@@ -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"]
@@ -56,9 +56,10 @@ def test_상태_범례_접기_한_줄_기억() -> None:
def test_상태_글판_작게_반투명_자리() -> None:
info = _rule(".b05-routeedit__info")
# 왼쪽 아래 제자리 — 뜬 판이 덮으면 가려진 채(349-0) · 폭 줄임
assert "bottom: var(--spacing-12);" in info
assert "max-width: min(460px, 46%);" in info and "grid-template-columns:" in info
# 왼쪽 아래 제자리 — 뜬 판이 덮으면 그 위로(408-2) · 폭 줄임
assert "bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));" in info
assert "max-width: min(460px, 46%, var(--b05-bottom-half));" in info # 408-4 반 폭
assert "grid-template-columns:" in info
# 상태 글 두 줄에서 자름 · 범례는 옆 칸 두 줄
assert "-webkit-line-clamp: 2;" in _rule(".b05-routeedit__status")
assert "flex-direction: column;" in _rule(".b05-routeedit__legend")
+15 -3
View File
@@ -38,7 +38,7 @@ def test_반지름_40_60_곡선이_바뀜(got, snap) -> None:
row = got[snap]
assert row["r40"] == pytest.approx(40) and row["r60"] == pytest.approx(60)
assert row["r60Shown"] == pytest.approx(60)
assert "반지름을 바꿨습니다." in row["r60Status"]
assert "반지름을 바꿨습니다" in row["r60Status"]
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
@@ -47,7 +47,7 @@ def test_곡선_길이_18_25_곡선이_바뀜(got, snap) -> None:
tol = 0.1 if snap == "snapOn" else 1e-6 # 스냅 켬 = IP 맞춤으로 교각이 조금 바뀐 뒤 값
assert row["l18"] == pytest.approx(18, abs=tol) and row["l25"] == pytest.approx(25, abs=tol)
assert row["l25Shown"] == pytest.approx(row["l25"])
assert "곡선 길이를 바꿨습니다." in row["l25Status"]
assert "곡선 길이를 바꿨습니다" in row["l25Status"]
def test_스냅_켬_MC_가_측점에(got) -> None:
@@ -55,6 +55,18 @@ def test_스냅_켬_MC_가_측점에(got) -> None:
assert got["snapOff"]["r60Mc"] != pytest.approx(120, abs=0.05)
def test_스냅_켬_R_바꾼_뒤_뒤쪽_곡선_MC(got) -> None:
"""408-1 — 고친 곡선 뒤 곡선도 맞춤 · 측점 위였던 곡선은 R 바꾼 뒤에도 측점 위."""
on = got["snapOn"]
def hit(mc: float) -> bool:
return abs(mc - round(mc / 20) * 20) <= 0.05
assert [hit(mc) for mc in on["r60Later"]] >= [hit(mc) for mc in on["r40Later"]]
assert all(hit(mc) for mc in on["r60Later"])
assert "함께 밀린 교점 " in on["r60Status"] and "함께 밀린" not in got["snapOff"]["r60Status"]
@pytest.mark.parametrize("snap", ["snapOff", "snapOn"])
def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -> None:
row = got[snap]
@@ -62,7 +74,7 @@ def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -
want = f"반지름 60m → {round(row['capR'], 1)}m(앞뒤 직선이 짧아 접선 자리 모자람)"
assert want in row["capStatus"]
assert "곡선 길이 40m → " in row["capArcStatus"]
assert "반지름을 자동으로 되돌렸습니다." in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음
assert "반지름을 자동으로 되돌렸습니다" in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음
def test_고정(got) -> None:
@@ -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,130 @@
"""PLAN 407-3 — 구조물을 MC 위에 놓았다 빼면 서버 종단 정본의 곡선 측점이 되살아남.
[저장] 차례(관 · 구조물 → 횡단) 의 `ensure_structure_stations` 길 — `_curve_marks`(어긋남 판정) ·
`sample_structure_sections(with_curves=True)`(곡선 측점도 뜸) · `_merge_irregular_into_longitudinal`
(옛 곡선 측점도 새 목록이 갈음). 노선 저장을 안 기다림 · 놓은 동안엔 그 자리 구조물 측점 하나.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Engine_Sections as sections # noqa: E402
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
from B05_Profile.B05_Profile_Router_Confirm import ( # noqa: E402
_merge_irregular_into_longitudinal,
)
from B06_Section.B06_Section_Router_Stations import _curve_marks # noqa: E402
LINE = [[0.0, 0.0, 100.0], [1000.3, 0.0, 100.0]]
SHORT = {"start": [101.0, 0.0], "apex": [107.3, 2.0], "end": [113.6, 0.0], "radius_m": 30.0}
LONG = {"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0}
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)
@pytest.fixture
def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
"""노선 · 곡선표 · 관 · 구조물 정본 대신 — 바꿀 수 있는 구조물 측점 하나(`extras`)."""
state: dict = {"extras": ()}
monkeypatch.setattr(sections, "_load_route_polyline", lambda *_: LINE)
monkeypatch.setattr(sections, "_load_design_curves", lambda *_: [SHORT, LONG])
monkeypatch.setattr(sections, "load_station_snap", lambda *_: False)
monkeypatch.setattr(sections, "load_station_snap_step", lambda *_: None)
monkeypatch.setattr(sections, "build_surface_sampler", lambda *_, **__: _Flat())
monkeypatch.setattr(sections, "_load_pipe_points", lambda *_: [])
monkeypatch.setattr(sections, "resolve_extra_stations", lambda *_: state["extras"])
# 노선 저장 때 정본 = 전체 생성(곡선 측점 107 · 631)
full = core.generate_sections(LINE, _Flat(), design_curves=[SHORT, LONG])["longitudinal"]
folder = tmp_path / "B06_Section" / "longitudinal"
folder.mkdir(parents=True)
(tmp_path / "B06_Section" / "cross_sections").mkdir()
(folder / "longitudinal.json").write_text(json.dumps(full), encoding="utf-8")
state["root"] = tmp_path
return state
def _save(state: dict) -> list[dict]:
"""[저장] 의 횡단 차례 — 어긋나면 다시 떠 병합(`ensure_structure_stations` 와 같은 길)."""
root = state["root"]
options = core.SectionGenerationOptions()
if _curve_marks(root, "route.json", options) == 0 and not state["extras"]:
return _stations(root)
fresh = sections.generate_irregular_sections(
root, "route.json", "f", "m", False, extra_stations=state["extras"], options=options
)
_merge_irregular_into_longitudinal(root, "B06_Section/longitudinal/longitudinal.json", fresh)
return _stations(root)
def _stations(root: Path) -> list[dict]:
path = root / "B06_Section" / "longitudinal" / "longitudinal.json"
return json.loads(path.read_text(encoding="utf-8"))["stations"]
def _at(stations: list[dict], chainage: float) -> list[str]:
return [s["kind"] for s in stations if abs(s["chainage_m"] - chainage) < 0.01]
def test_처음은_맞음(project) -> None:
assert _curve_marks(project["root"], "route.json", core.SectionGenerationOptions()) == 0
assert _at(_stations(project["root"]), 107.0) == ["curve"]
def test_MC_위_구조물_놓음_뺌(project) -> None:
root = project["root"]
project["extras"] = ((107.0, "관"),)
assert _curve_marks(root, "route.json", core.SectionGenerationOptions()) == 1
placed = _save(project)
assert _at(placed, 107.0) == ["irregular"] # 놓은 동안 = 구조물 측점 하나
assert _at(placed, 631.0) == ["curve"] # 다른 곡선 측점은 그대로
assert list((root / "B06_Section" / "cross_sections").glob("cross_*.json"))
project["extras"] = ()
assert _curve_marks(root, "route.json", core.SectionGenerationOptions()) == 1
removed = _save(project)
assert _at(removed, 107.0) == ["curve"] # 노선 저장 없이 되살아남
assert _at(removed, 631.0) == ["curve"]
assert _curve_marks(root, "route.json", core.SectionGenerationOptions()) == 0
assert len(removed) == len(_stations_full())
def _stations_full() -> list[dict]:
return core.generate_sections(LINE, _Flat(), design_curves=[SHORT, LONG])["longitudinal"][
"stations"
]
def test_planned_curve_stations_는_생성과_같음() -> None:
extras = ((107.0, "관"), (300.0, "옹벽"))
for snap, step in [(False, None), (True, None), (True, 0.1)]:
options = core.SectionGenerationOptions(extra_stations=extras)
made = core.generate_sections(
LINE,
_Flat(),
options,
design_curves=[SHORT, LONG],
station_snap=snap,
station_snap_step=step,
)["longitudinal"]["stations"]
planned = core.planned_curve_stations(
LINE,
options,
design_curves=[SHORT, LONG],
station_snap=snap,
station_snap_step=step,
)
assert planned == [s["chainage_m"] for s in made if s["kind"] == "curve"]
@@ -0,0 +1,96 @@
"""PLAN 407-4 — 종단 측점 · 구조물 이름표 비켜 그리기(`logic/B06_Section_Label_Tiers.ts`) ·
407-5 — B05 종단 아래 표 겹친 칸 세우기(`crowdedThickness` · 두께 = 이웃 사이 틈 − 1).
가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단 · 안 겹치면 제자리(0) · 이름은 지우지 않음 ·
단 수 위 끝(`MAX_LABEL_TIER`) 넘으면 마지막 단 · 글 폭 짐작(한글 = 글자 크기).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS = ROOT / "B06_Section" / "logic" / "B06_Section_Label_Tiers.ts"
TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript"
SCRIPT = r"""
const ts = require(process.argv[1]);
const fs = require("fs");
const js = ts.transpileModule(fs.readFileSync(process.argv[2], "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
const m = box.exports;
const cases = JSON.parse(process.argv[3]);
process.stdout.write(JSON.stringify({
max: m.MAX_LABEL_TIER,
tiers: cases.map((items) => m.labelTiers(items)),
widths: ["19+18 (MC)", "20+0", "배수관"].map((t) => m.labelWidthPx(t, 10)),
thick: [
m.crowdedThickness([0, 50, 100], 40, 16),
m.crowdedThickness([100, 108, 200], 40, 16),
m.crowdedThickness([100, 130, 200], 40, 16),
m.crowdedThickness([100, 104, null, 106], 40, 16),
m.crowdedThickness([100, null, 103], 40, 16),
],
}));
"""
W = 40.0 # 이름표 폭(px)
CASES = [
[{"x": 100, "width": W}, {"x": 200, "width": W}, {"x": 300, "width": W}], # 안 겹침
[{"x": 100, "width": W}, {"x": 108, "width": 24}], # 1.6 m 이웃 — 겹침
[{"x": 108, "width": 24}, {"x": 100, "width": W}], # 차례 뒤바뀜(x 로 셈)
[{"x": 100 + i * 2, "width": W} for i in range(6)], # 여섯 겹 — 위 끝
[{"x": 100, "width": W}, {"x": 110, "width": W}, {"x": 150, "width": W}], # 셋째는 제자리
]
@pytest.fixture(scope="module")
def r() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS), json.dumps(CASES)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_안_겹치면_제자리(r) -> None:
assert r["tiers"][0] == [0, 0, 0]
def test_겹치면_위_단(r) -> None:
assert r["tiers"][1] == [0, 1]
assert r["tiers"][2] == [1, 0] # 왼쪽 것이 제자리
def test_위_끝_넘으면_마지막_단(r) -> None:
assert r["tiers"][3] == [0, 1, 2, 3, 3, 3] # 이름은 남음(지우지 않음)
def test_빈_단은_다시_씀(r) -> None:
assert r["tiers"][4] == [0, 1, 0]
def test_글_폭(r) -> None:
mc, plain, korean = r["widths"]
assert mc == pytest.approx(60.0) and plain == pytest.approx(24.0)
assert korean == pytest.approx(30.0)
def test_표_칸_세우기(r) -> None:
no, near, wide, empty, floor = r["thick"]
assert no == [None, None, None] # 안 겹침 = 그대로
assert near == [7, 7, None] # 1.6 m 이웃(8 px) = 둘 다 세움 · 두께 7
assert wide == [16, 16, None] # 틈 넓으면 위 끝 16
assert empty == [6, 6, None, 6] # 빈 칸(null) 건너 이웃 · 아래 끝 6
assert floor == [6, None, 6]
+173
View File
@@ -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,47 @@
"""408-2 창 나눔(종단 판 열림) 때 평면 상태줄이 종단 표 밑에 깔림 — 판이 덮은 높이만큼 상태 · 범례를
위로(`logic/B05_Profile_RouteEdit_InfoLift.ts` · `_Chrome` 이 판 크기 관찰로 `--b05-profile-cover` 적음).
TS 셈은 `helper_408_2_status_lift.cjs`(Node).
./venv/Scripts/python.exe -m pytest resources/tester/test_408_2_status_lift.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_408_2_status_lift.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=60
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_올릴_높이(got) -> None:
assert got["open"] == 648 # 판 윗변 위로
assert got["collapsed"] == 1 and got["popout"] == 0 and got["below"] == 0
assert got["tall"] == 1078 - 120 # 지도 안에 120 px 남김
def test_상태_글판이_올림_값을_씀() -> None:
css = (B05 / "B05_Profile_UI_Style_RouteEdit.css").read_text(encoding="utf-8")
block = css[css.index(".b05-routeedit__info {\n bottom") :]
assert block.startswith(
".b05-routeedit__info {\n bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));"
)
def test_편집기가_판_크기를_관찰() -> None:
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
assert "new ResizeObserver(liftInfo)" in chrome and "lift.observe(overlay)" in chrome
assert '"--b05-profile-cover"' in chrome and ".b05-route-profile" in chrome
assert "lift.disconnect" not in chrome # 붙기 전 첫 알림에 끊으면 영영 안 올라감
@@ -0,0 +1,28 @@
"""408-3 잰 값 창 · 오른쪽 횡단 판(손잡이)도 뜬 종단 판 위로 — 408-2 올림 값(`--b05-profile-cover`) 재사용.
판 열림 때 잰 값 창은 판 밑에 깔리고, 횡단 판 손잡이(판 가운데 높이)도 판 밑이라 횡단을 못 열었음.
./venv/Scripts/python.exe -m pytest resources/tester/test_408_3_measure_side_lift.py -q
"""
from pathlib import Path
import pytest
CSS = Path(__file__).resolve().parents[2] / "B05_Profile" / "B05_Profile_UI_Style_RouteEdit.css"
LIFT = "bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));"
def _rules(selector: str) -> list[str]:
"""그 선택자의 규칙 덩이 모두(같은 선택자가 여러 덩이에 나뉨)."""
css = CSS.read_text(encoding="utf-8")
parts = css.split(f"{selector} {{")[1:]
return [part[: part.index("}")] for part in parts]
@pytest.mark.parametrize(
"selector",
[".b05-routeedit__info", ".b05-routeedit__measure", ".b05-routeedit__side-wrap"],
)
def test_지도_아래_글판_판은_종단_판_위로(selector) -> None:
assert any(LIFT in rule for rule in _rules(selector))
+65
View File
@@ -0,0 +1,65 @@
"""408-4 횡단 판이 넓을 때 상태 글판 · 잰 값 창 겹침 — 횡단 판 왼쪽 빈 폭을 반씩 나눠 쓰고, 반이 200 px
아래면(`bottomRowNarrow`) 잰 값 창을 상태 글판 위로 쌓음(둘 다 빈 폭 전부 · 좁으면 줄바꿈).
TS 셈은 `helper_408_2_status_lift.cjs`(Node · `logic/B05_Profile_RouteEdit_InfoLift.ts`).
./venv/Scripts/python.exe -m pytest resources/tester/test_408_4_bottom_row.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
B05 = Path(__file__).resolve().parents[2] / "B05_Profile"
CSS = B05 / "B05_Profile_UI_Style_RouteEdit.css"
HELPER = Path(__file__).with_name("helper_408_2_status_lift.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=60
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def _rules(selector: str) -> list[str]:
css = CSS.read_text(encoding="utf-8")
return [part[: part.index("}")] for part in css.split(f"{selector} {{")[1:]]
def test_반_폭이_모자라면_쌓음(got) -> None:
# (1172 - 36 - 736) / 2 = 200 → 나란히 · 737 → 쌓음
assert got["narrow"] == [False, False, True, False, True]
def test_두_글판이_반_폭까지만() -> None:
assert any(
"max-width: min(460px, 46%, var(--b05-bottom-half));" in r
for r in _rules(".b05-routeedit__info")
)
assert any("max-width: var(--b05-bottom-half);" in r for r in _rules(".b05-routeedit__measure"))
half = _rules(".b05-routeedit")[0]
assert (
"--b05-bottom-half: max(0px, calc((100% - 3 * var(--spacing-12) - var(--b05-side-open)) / 2));"
in half
)
def test_좁으면_잰_값_창이_상태_글판_위로() -> None:
narrow = _rules(".b05-routeedit.is-narrow-bottom")[0]
assert "calc(100% - 2 * var(--spacing-12) - var(--b05-side-open))" in narrow
measure = _rules(".b05-routeedit.is-narrow-bottom .b05-routeedit__measure")[0]
assert "var(--b05-info-h, 0px)" in measure and "var(--b05-profile-cover, 0px)" in measure
def test_편집기가_횡단_판_글판_크기를_관찰() -> None:
chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
assert (
"lift.observe(info)" in chrome
and 'lift.observe(overlay.querySelector(".b05-routeedit__side-wrap")!)' in chrome
)
assert '"is-narrow-bottom"' in chrome and '"--b05-info-h"' in chrome
@@ -0,0 +1,75 @@
"""408-1 R 바꿈 뒤 뒤쪽 측점 맞춤(요구 252-3) — 반지름 · 곡선 길이 입력 = 교점 끌기와 같은
`fitCurveStations` 로 고친 곡선부터 끝 곡선까지 MC 를 측점에 다시 맞춤 · 상태줄 「함께 밀린 교점 N곳 ·
못 맞춘 곡선 No.…」(`KeyNudge.fitNote`) · 스냅 끔 = 맞춤 · 보고 없음.
TS 셈은 `helper_408_radius_downstream.cjs`(Node · `_CurveBar` 배선 그대로).
./venv/Scripts/python.exe -m pytest resources/tester/test_408_radius_downstream.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
HELPER = Path(__file__).with_name("helper_408_radius_downstream.cjs")
TOL = 0.05 # MC_TOL_M
STEPS = ["R60", "L50", "Auto"]
@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 _on_station(mc: float) -> bool:
return abs(mc - round(mc / 20) * 20) <= TOL
@pytest.mark.parametrize("step", STEPS)
def test_스냅_켬_뒤쪽_곡선_MC_가_측점_위(got, step) -> None:
row = got[f"on{step}"]
assert len(row["mcs"]) == 4 and all(_on_station(mc) for mc in row["mcs"])
assert row["off"] <= got["onStart"]["off"]
@pytest.mark.parametrize("step", STEPS)
def test_스냅_켬_뒤_교점이_밀림_시점은_그대로(got, step) -> None:
before = got["onStart"]["planned"]
after = got[f"on{step}"]["planned"]
assert after[0] == before[0] and after[-1] == before[-1]
assert all(after[k] != before[k] for k in (2, 3, 4)) # 고친 곡선 뒤 교점이 맞춤으로 밀림
def test_상태줄_보고(got) -> None:
assert "반지름을 바꿨습니다 · 함께 밀린 교점 4곳." in got["onR60"]["status"]
assert "곡선 길이를 바꿨습니다 · 함께 밀린 교점 4곳." in got["onL50"]["status"]
assert "반지름을 자동으로 되돌렸습니다 · 함께 밀린 교점 " in got["onAuto"]["status"]
assert all("못 맞춘 곡선" not in got[f"on{step}"]["status"] for step in STEPS)
@pytest.mark.parametrize("step", STEPS)
def test_스냅_끔_맞춤_보고_없음(got, step) -> None:
row = got[f"off{step}"]
assert "함께 밀린" not in row["status"]
assert row["planned"] == got["offStart"]["planned"] # 교점 그대로
assert not all(_on_station(mc) for mc in row["mcs"])
def test_고정_교점_곡선은_못_맞춤으로(got) -> None:
row = got["lockR60"]
assert row["off"] == got["lockStart"]["off"] == 1
assert row["planned"][3] == got["lockStart"]["planned"][3] # 고정 IP3 그대로
mc = row["mcs"][2]
n, rest = int(mc // 20), round(mc - int(mc // 20) * 20)
assert f"함께 밀린 교점 3곳 · 못 맞춘 곡선 No.{n}+{rest}." in row["status"]
assert all(_on_station(row["mcs"][i]) for i in (0, 1, 3)) # 고정 곡선 뒤도 맞춤
@@ -0,0 +1,122 @@
"""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
# ── 409-2 마커 고름 풂 = 좌측 목록 강조 · 폼도 풂 · 패널 접힘 · 펼침 그대로 · 오른쪽 판 그대로 ──
HELPER_2 = Path(__file__).with_name("helper_409_2_plan_release_form.cjs")
@pytest.fixture(scope="module")
def got2() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER_2)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_풂_관_폼_닫힘_오른쪽_판은_제_알림만(got2) -> None:
release = got2["pipeRelease"]
assert release["form"] == {"open": None, "resets": 1}
assert release["crossSaw"] == [[None, "b05-plan"]] # 폼이 낸 b05 풂은 알림 안 감(되울림 막이)
assert release["session"] is None
def test_풂_구조물_폼_닫힘(got2) -> None:
assert got2["structureRelease"] == {"form": {"open": None, "resets": 2}, "pill": None}
def test_눈금_클릭은_폼_그대로(got2) -> None:
assert got2["tickKeepsForm"] == "A"
assert got2["noFormNoReset"] == 0
assert got2["planName"] == "b05-plan"
def test_폼_닫기는_패널_안_폄() -> None:
panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts")
head = panel[panel.index(" selectById(structureId) {") :]
assert head.index("if (structureId !== null) callbacks.onReveal?.();") < head.index(
"resetForm();"
)
assert "is-collapsed" not in head[: head.index(" addAt(")] # 접힘 · 펼침 안 건드림
pick = _read("A00_Common", "design", "design_pick_profile.ts")
assert "from === PLAN_PICK_FROM && selection.chainageM === null" in pick
@@ -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