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

This commit is contained in:
2026-10-18 10:08:04 +09:00
79 changed files with 2906 additions and 212 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);
+31
View File
@@ -46,6 +46,20 @@ export function pipeIdAt(chainageM: number, projectId: string | null = active):
return id;
}
/** 그 자리 관의 id — 장부에 있을 때만(안 지음 · 411-E). */
export function pipeIdNear(chainageM: number, projectId: string | null = active): string | null {
if (!projectId) return null;
return nearestWithin(bookOf(projectId).ids, chainageM, (entry) => entry.at)?.id ?? null;
}
/** 지금 관 자리(`keep`) 근처에 없는 기록을 뺌 — 옮긴 사이 옛 측점이 지은 헛 id 정리(411-E). */
export function prunePipeIds(keep: ReadonlyArray<number>, projectId: string | null = active): void {
if (!projectId || !keep.length) return; // 빈 목록 = 아직 못 받음 · 다 지움 — 장부 그대로
const book = bookOf(projectId);
const ids = book.ids.filter((entry) => keep.some((at) => isNear(entry.at, at)));
if (ids.length !== book.ids.length) writeState("pipe-ids", { next: book.next, ids }, projectId);
}
/** id 의 관 자리 — 장부 · 옛 자리 이름 순 · 관 id 가 아니면 null. */
export function pipeChainageOf(id: string, projectId: string | null = active): number | null {
if (!id.startsWith("pipe-")) return null;
@@ -73,6 +87,23 @@ export function movePipeId(fromM: number, toM: number, projectId: string | null
return moving.id;
}
/** 관 세션 id 를 그 자리로(화면 목록 맞춤 · 411-B) — 짝 = 관 고정 id 가 쥔 세션 id(노선 따라감 · 끌기 · 되돌리기
* 어느 길로 옮겨도 그 id) · 그 자리 근처의 다른 기록(옛 측점 열쇠가 지은 헛 id)은 뺌 · 장부에 없는 id 는 적음. */
export function placePipeIds(
places: ReadonlyArray<readonly [string, number]>,
projectId: string | null = active,
): void {
if (!projectId || !places.length) return;
const book = bookOf(projectId);
const to = new Map(places);
const ids = book.ids
.filter((entry) => to.has(entry.id) || !places.some(([, at]) => isNear(entry.at, at)))
.map((entry) => (to.has(entry.id) ? { ...entry, at: to.get(entry.id)! } : entry));
for (const [id, at] of places) if (!ids.some((entry) => entry.id === id)) ids.push({ id, at });
if (JSON.stringify(ids) !== JSON.stringify(book.ids))
writeState("pipe-ids", { next: book.next, ids }, projectId);
}
/** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */
export function withPipeIds<T extends { chainage_m: number }>(
pipes: ReadonlyArray<T>,
+21 -3
View File
@@ -91,7 +91,10 @@ import {
draftSectionDetail,
type CrossInput,
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
import {
buildStations,
SECTION_STATION_INTERVAL_M,
} from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage";
import {
createMassHaulStage,
@@ -314,11 +317,26 @@ function createPagePipeline(
replay = false;
if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로)
const line = ctx.before.route?.line ?? savedLine() ?? [];
// 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 가게 값 = 이미 초안 노선 자리(앞 따라감이 씀 · 410)
const frame = (list: unknown, saved: unknown) =>
!ctx.before.route && ctx.inputs.route && list !== saved
? (ctx.out.route?.line ?? line)
: line;
const { pipes, structures } = ctx.inputs;
// 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의 앞 노선 위(410)
const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[];
const inputs = {
...ctx.inputs,
pipes: placed(pipes, line, savedPipes as FollowingStructure[]),
structures: placed(structures, line, []),
pipes: placed(pipes, frame(pipes, savedPipes), [
...(savedPipes as FollowingStructure[]),
...followed,
]),
structures: placed(
structures,
frame(structures, savedStructures),
[],
SECTION_STATION_INTERVAL_M,
),
};
return followStage({ ...ctx, inputs });
},
+27 -13
View File
@@ -66,7 +66,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css";
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import { movePipeId, pipeIdAt } from "./design_pipe_ids";
import { pipeIdAt, placePipeIds, prunePipeIds } from "./design_pipe_ids";
import { useDesignStore } from "./design_store";
import { setPlusStations } from "../../common_util/common_util_structure_station";
import {
@@ -113,19 +113,29 @@ export async function mountProfileView(
/** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */
let lastPipes: PipeReport | null = shared.lastPipes ?? null;
const designStore = useDesignStore(activeProjectId, null);
/** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 id 도 옮김. */
let lastShown: PipeReport = [];
/** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 세션 id 도
* 그 자리로 — 짝 = 관 고정 id(411-B · 앞 관을 지나 옮겨도 · 되돌려도 그 관) · id 없는 관(막 놓은 관)은 자리로. */
let sessionIds = new Map<string, string>(); // 관 고정 id → 세션 id(알약 id)
const shownPipes = (pipes: PipeReport): PipeReport => {
const byChainage = (x: PipeReport[number], y: PipeReport[number]) =>
x.chainage_m - y.chainage_m;
const shown = followPipeReports(pipes, designStore.get("pipes")).sort(byChainage);
const before = [...lastShown].sort(byChainage);
if (before.length === shown.length)
shown.forEach((pipe, i) => {
if (Math.abs(pipe.chainage_m - before[i].chainage_m) > 0.005)
movePipeId(before[i].chainage_m, pipe.chainage_m, activeProjectId);
});
lastShown = shown;
const shown = followPipeReports(pipes, designStore.get("pipes")).sort(
(x, y) => x.chainage_m - y.chainage_m,
);
placePipeIds(
shown.flatMap((pipe) => {
const sid = pipe.id && sessionIds.get(pipe.id);
return sid ? [[sid, pipe.chainage_m] as const] : [];
}),
activeProjectId,
);
sessionIds = new Map(
shown.flatMap((pipe) =>
pipe.id ? [[pipe.id, pipeIdAt(pipe.chainage_m, activeProjectId)] as const] : [],
),
);
prunePipeIds(
shown.map((pipe) => pipe.chainage_m),
activeProjectId,
); // 헛 id 정리(411-E)
return shown;
};
const applyPipes = (reported: PipeReport, carryEdits = true): void => {
@@ -145,6 +155,10 @@ export async function mountProfileView(
`구조물 ${far}건이 새 노선에서 ${STRUCTURE_ROUTE_DRIFT_MAX_M} m 넘게 멀어짐`,
"warning",
);
// 배수 편집기도 따라간 관 자리(410) — 초안을 버려 가게가 비면 저장분 다시 읽음
const followed = designStore.get("pipes");
if (followed) profilePanel.drainage.followPipes(followed);
else if (profilePanel.drainage.pipeChainages().length) profilePanel.drainage.reload();
if (!host.visible()) return;
if (lastPipes) applyPipes(lastPipes, false);
bridge.takeStructures(designStore.get("structures"));
@@ -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;
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
@@ -128,6 +128,9 @@ export interface StructureInstance {
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
group_items?: GroupItems | null;
/** 땅 위 자리(344-2 · 410) — 서버가 채움 · 누가거리만 옮기면 낡음(따라감 · 저장이 노선 위로 다시). */
x?: number | null;
y?: number | null;
}
interface StructureTypesResponse {
+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 + 글」 포함)
@@ -59,6 +59,7 @@ import {
basinIndexOfPipe,
pipeMarkerColor,
adoptDraftPipes,
followEditorPipes,
reconcilePipes,
WIDTH_KEY,
type DrainageLayer,
@@ -569,6 +570,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
if (projectId && meta) void run(loadSaved);
},
pipeChainages: () => pipeEditor.chainages(),
followPipes: (pipes) => followEditorPipes(pipeEditor, facilityStore, pipes),
movePipe(fromChainage, toChainage) {
const index = pipeEditor
.pipes()
@@ -25,6 +25,8 @@ export interface DrainagePanel {
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
/** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */
pipeChainages: () => number[];
/** 노선 초안 follow 가 다시 매긴 관 자리를 받음(짝 = 관 id · 재계산 없음 · 410). */
followPipes: (pipes: ReadonlyArray<{ id?: string | null; chainage_m: number }>) => void;
/** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */
movePipe: (fromChainage: number, toChainage: number) => void;
/** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우).
@@ -64,6 +66,8 @@ export interface DrainagePanel {
/** 관 하나의 보고 — 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류 · 구간 · 옵션 · 집계 항목. */
export interface DrainagePipeReport {
chainage_m: number;
/** 관 고정 id(서버 · 새 관은 없음) — 따라감 · 옮김 짝(411-B). */
id?: string;
effective_diameter_mm: number | null;
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
design_flow_m3s?: number | null;
@@ -22,6 +22,7 @@ import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_U
import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes";
import type { FacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { viewOf } from "../common_util/common_util_view_window";
@@ -341,6 +342,42 @@ export function mountDrainageToggles(
);
}
/** 노선 초안 follow 가 다시 매긴 관 자리(가게 관 초안)를 편집기에 얹음(410) — 짝 = 관 id(막 놓아 초안에 id
* 없는 관끼리는 누가거리 차례 · 수가 같을 때만) · 시설 속성 · 고름도 따라감 · 재계산 · 초안 쓰기 없음(가게가
* 이미 그 값). 편집기가 옛 노선 누가거리로 초안을 덮지 않게. */
export function followEditorPipes(
editor: PipeEditor,
facilities: FacilityStore,
followed: ReadonlyArray<{ id?: string | null; chainage_m: number }>,
): void {
const to = new Map<PipePoint | string, number>(
followed.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : [])),
);
const before = editor.pipes();
const byOrder = (a: { chainage_m: number }, b: { chainage_m: number }) =>
a.chainage_m - b.chainage_m;
const looseEditor = before.filter((pipe) => !(pipe.id && to.has(pipe.id))).sort(byOrder);
const looseFollowed = followed.filter((pipe) => !pipe.id).sort(byOrder);
if (looseEditor.length === looseFollowed.length)
looseEditor.forEach((pipe, i) => to.set(pipe, looseFollowed[i].chainage_m));
const moves = before.flatMap((pipe) => {
const at = (pipe.id ? to.get(pipe.id) : undefined) ?? to.get(pipe);
return at === undefined || Math.abs(at - pipe.chainage_m) < 0.005
? []
: [{ pipe, at, attributes: facilities.get(pipe.chainage_m) }];
});
if (!moves.length) return;
const picked = before[editor.selected() ?? -1];
moves.forEach(({ pipe }) => facilities.set(pipe.chainage_m, null)); // 다 뺀 뒤 얹음 — 자리 겹침 막음
moves.forEach(({ at, attributes }) => attributes && facilities.set(at, attributes));
const moved = new Map(moves.map(({ pipe, at }) => [pipe, at]));
editor.setPipes(
before.map((pipe) => ({ ...pipe, chainage_m: moved.get(pipe) ?? pipe.chainage_m })),
);
const pickedAt = picked && (moved.get(picked) ?? picked.chainage_m);
if (picked) editor.select(editor.pipes().findIndex((pipe) => pipe.chainage_m === pickedAt));
}
/** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다.
* 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */
/** 되돌리기(369) — 되살린 걸음의 관 초안(세션)이 `next` 와 같은 자리면 그 초안(속성 정본) · id 도
@@ -15,7 +15,7 @@ import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
* 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
* 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). */
export function buildPipeReports(
pipes: ReadonlyArray<{ chainage_m: number; reason?: string }>,
pipes: ReadonlyArray<{ chainage_m: number; reason?: string; id?: string }>,
basins: ReadonlyArray<DetailBasin>,
facilityStore: ReturnType<typeof createFacilityStore>,
): DrainagePipeReport[] {
@@ -25,6 +25,7 @@ export function buildPipeReports(
const attributes = facilityStore.get(chainage);
return {
chainage_m: chainage,
...(pipe.id ? { id: pipe.id } : {}), // 관 고정 id — 옮겨도 짝(411-B)
effective_diameter_mm:
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
@@ -18,6 +18,8 @@ export interface RouteProfilePanelCallbacks {
onPipesChanged?: (
pipes: Array<{
chainage_m: number;
/** 관 고정 id(서버 · 새 관은 없음) — 따라감 · 옮김 짝(411-B). */
id?: string;
effective_diameter_mm: number | null;
/** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */
design_flow_m3s?: number | null;
+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 {
@@ -1,8 +1,8 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Pipeline_Follow.ts
* 노선이 움직인 뒤 관 목록 화면 값(PLAN 349-0d) — 배수유역도가 알린 관 목록(옛 누가거리)을 가게 관 초안
* (계산 묶음 follow 가 좌표로 다시 매긴 누가거리)에 맞춤. 짝 = 누가거리 차례(따라감은 좌표 투영이라
* 차례가 거의 그대로) · 관 수가 다르면 그대로(배수유역도가 아직 안 받은 고침).
* (계산 묶음 follow 가 좌표로 다시 매긴 누가거리)에 맞춤. 짝 = 관 고정 id(411-B · 옮겨 차례가 바뀌어도
* 그 관) · id 없는 관(막 놓은 관)끼리는 누가거리 차례 · 관 수가 다르면 그대로(배수유역도가 아직 안 받은 고침).
* 순수 계산(DOM · fetch · 세션 없음).
* ========================================================================== */
@@ -10,6 +10,7 @@ interface Span {
chainage_m: number;
start_m?: number | null;
end_m?: number | null;
id?: string | null;
}
/** 관 알림 목록의 측점 · 구간을 가게 관 초안 쪽으로 — 초안이 없으면(저장분) 그대로. */
@@ -19,9 +20,15 @@ export function followPipeReports<R extends Span>(
): R[] {
if (!pipes || pipes.length !== reports.length) return [...reports];
const byChainage = (a: Span, b: Span): number => a.chainage_m - b.chainage_m;
const to = [...pipes].sort(byChainage);
return [...reports].sort(byChainage).map((report, index) => {
const pipe = to[index];
const byId = new Map(pipes.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe]] : [])));
const paired = (report: R): boolean => Boolean(report.id && byId.has(report.id));
const loose = [...pipes].filter((pipe) => !reports.some((r) => r.id && r.id === pipe.id));
const to = loose.sort(byChainage);
const sorted = [...reports].sort(byChainage);
let next = 0;
return sorted.map((report) => {
const pipe = paired(report) ? byId.get(report.id!)! : to[next++];
if (!pipe) return report;
const chainage = Math.round(pipe.chainage_m * 100) / 100; // 알림 목록 자릿수(`buildPipeReports`)
const shift = chainage - report.chainage_m;
const moved = (own: number | null | undefined, from: number | null | undefined) =>
@@ -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,
@@ -1,8 +1,8 @@
/* =============================================================================
* B05_Profile_Structure_Follow.ts
* 노선 초안 동안 종단 알약 자리(PLAN 346-2 덧 3) — 계산 묶음 follow 가 좌표로 따라간 관 · 구조물의 새
* 누가거리를 알약 목록에 얹음. 구조물 = structure_id 로 짝 · 관 알약 id = 세션 id(`pipeIdAt`) 라 묶음 관 id
* (서버 고정 id)와 다르면 누가거리 차례로 짝.
* 누가거리를 알약 목록에 얹음. 구조물 = structure_id 로 짝 · 관 알약 id = 세션 id(`pipeIdAt` · 자리 장부)라
* 묶음 관 id(서버 고정 id)와 다르면 같은 자리(장부가 따라간 자리 = 그 관 · 411-B) → 남은 것끼리 누가거리 차례.
*
* 순수 — 화면 · fetch · 세션 없음.
* ========================================================================== */
@@ -22,6 +22,9 @@ interface Moved extends Placed {
structure_id?: string | null;
}
/** 같은 관으로 볼 자리 차이(m) — `PIPE_MATCH_M`(design_selection) 같은 값. */
const SAME_PIPE_M = 0.51;
const list = (value: unknown): Moved[] => (Array.isArray(value) ? (value as Moved[]) : []);
const at = (item: Placed): number => item.chainage_m ?? item.start_m ?? 0;
@@ -35,14 +38,21 @@ export function followedMarks<T extends Mark>(
for (const item of list(structures)) if (item.structure_id) byId.set(item.structure_id, item);
const movedPipes = list(pipes).filter((pipe) => typeof pipe.chainage_m === "number");
for (const pipe of movedPipes) if (pipe.id) byId.set(pipe.id, pipe);
// ponytail: 차례 짝 — 노선을 고쳐 관 차례가 뒤바뀌면 틀림 · 알약이 서버 관 id 를 들면 id 짝으로
const loose = marks
.filter((mark) => mark.structure_id?.startsWith("pipe-") && !byId.has(mark.structure_id))
.sort((a, b) => at(a) - at(b));
const ids = new Set(marks.map((mark) => mark.structure_id));
const free = movedPipes.filter((pipe) => !ids.has(pipe.id)).sort((a, b) => at(a) - at(b));
let free = movedPipes.filter((pipe) => !ids.has(pipe.id)).sort((a, b) => at(a) - at(b));
const paired = new Map<T, Moved>();
if (loose.length === free.length) loose.forEach((mark, index) => paired.set(mark, free[index]));
for (const mark of loose) {
const same = free.find((pipe) => Math.abs(at(pipe) - at(mark)) < SAME_PIPE_M);
if (!same) continue;
paired.set(mark, same);
free = free.filter((pipe) => pipe !== same);
}
// ponytail: 남은 것은 차례 짝 — 알약이 옛 자리인 채 노선 고쳐 관 차례가 뒤바뀌면 틀림
const rest = loose.filter((mark) => !paired.has(mark));
if (rest.length === free.length) rest.forEach((mark, index) => paired.set(mark, free[index]));
return marks.map((mark) => {
const moved = (mark.structure_id ? byId.get(mark.structure_id) : undefined) ?? paired.get(mark);
if (!moved) return mark;
@@ -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)
+1 -1
View File
@@ -182,7 +182,7 @@ export async function withDraftStations(
if (inputs) {
const [pipes, structures, types] = inputs;
const planned = planStations(pipes, structures, types, registryDefaults(types));
useStationOwners(projectId, planned); // 구조물 측점 열쇠 = 덮는 구조물 id(344-3)
useStationOwners(projectId, planned, pipes); // 측점 열쇠 = 덮는 구조물 id(344-3) · 관 = pipe:
const planted = planned.map((station) => station.chainage_m);
// 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고
// 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴
+13 -6
View File
@@ -16,7 +16,7 @@
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import { pipeIdAt, pipeIdNear } from "../A00_Common/design/design_pipe_ids";
import { showToast } from "@ui/ui_template_elements";
import {
chainageKey,
@@ -58,17 +58,21 @@ let current: { projectId: string; routeId: number } | null = null;
const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`;
/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 세션 id 는 `pipe-` 머리. */
/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 = `pipe-` 머리(세션 · 자리 이름) 또는 `pipes` 의
* 서버 관 id(`p8017` · 계산 묶음 측점 열쇠 `pipe:p8017` 과 같은 규칙 · 411-D). */
export function useStationOwners(
projectId: string,
planned: ReadonlyArray<{ chainage_m: number; owners: string[]; role?: StationKeyRole }>,
pipes: ReadonlyArray<{ id?: string | null }> = [],
): void {
const pipeIds = new Set(pipes.map((pipe) => pipe.id));
owners.set(
projectId,
planned.flatMap((station) =>
station.owners.slice(0, 1).map((id) => ({
id,
kind: id.startsWith("pipe-") ? ("pipe" as const) : ("structure" as const),
kind:
id.startsWith("pipe-") || pipeIds.has(id) ? ("pipe" as const) : ("structure" as const),
at: station.chainage_m,
role: station.role ?? "anchor",
})),
@@ -112,15 +116,18 @@ export function stationKeysOf(
intervalM: number | null,
): Keyed[] {
const known = projectId ? (owners.get(projectId) ?? []) : [];
const planned = known.some((owner) => owner.kind === "pipe");
const keys = sections.map((section) => {
const at = section.chainage_m;
let key: string;
if (section.kind === "irregular") {
const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M);
if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role);
else if (looksLikePipe(section))
key = stationKey("pipe", at, intervalM, pipeIdAt(at, projectId));
else
else if (looksLikePipe(section)) {
// 관 계획을 아는 동안(초안) 계획에 없는 관 측점 = 옮겨 간 옛 자리 — 세션 id 를 새로 안 지음(411-E)
const id = planned ? pipeIdNear(at, projectId) : pipeIdAt(at, projectId);
key = id ? stationKey("pipe", at, intervalM, id) : stationKey("irregular", at, intervalM);
} else
key = section.grid
? stationKey("regular", at, intervalM)
: stationKey("irregular", at, intervalM);
@@ -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";
}
+23 -4
View File
@@ -5,12 +5,12 @@
새 노선에서 `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘게 멀면 「노선에서 멀어짐」 표시만(버리지 않음).
- `fill_structure_coordinates` — 저장 때 노선 위 기준 자리로 x · y 채움(자리가 그대로면 옛 좌표
지킴) · 옛 자료는 읽을 때 없는 것만 채움.
지킴 · 보낸 좌표가 그 누가거리를 낳으면 살림 · 410) · 옛 자료는 읽을 때 없는 것만 채움.
- `follow_structures` — 좌표 → 새 노선 누가거리 · 구간 끝은 같은 만큼 밂 · 노선과의 거리.
짝 = TS `common_util/common_util_route_follow.ts` `followStructures`(구조물) · `projectPipePoints`
(관 — 파이썬 본체 = `common_util_drainage_pipes.project_pipe_points`) · 거울 시험 =
`resources/tester/test_344_2_route_pairs.py`.
(관 — 파이썬 본체 = `common_util_drainage_pipes.project_pipe_points`) · `placeOnLine`(좌표 낡음
판정) · 거울 시험 = `resources/tester/test_344_2_route_pairs.py` · `test_410_follow_xy.py`.
"""
from __future__ import annotations
@@ -20,6 +20,7 @@ from typing import Any, Iterable, Sequence
from shapely.geometry import LineString, Point
from common_util.common_util_structure_station import (
SAME_STATION_M,
route_general_stations,
snap_existing_station,
snap_structure_station,
@@ -41,6 +42,20 @@ def _positions(item: Any) -> tuple[Any, ...]:
return tuple(getattr(item, key, None) for key in _POSITION_KEYS)
def _holds_station(item: Any, line: LineString) -> bool:
"""좌표가 그 기준 측점을 낳는가 — 같은 노선 따라감이 누가거리를 안 옮김(TS `holdsStation`)."""
at = float(line.project(Point(float(item.x), float(item.y))))
length = float(line.length)
target = (
snap_existing_station(at, route_general_stations(length))
if getattr(item, "placement", None) == "site"
else snap_structure_station(at, [length])
)
anchor = float(item.chainage_m)
shift = target - anchor
return abs(min(max(anchor + shift, 0.0), length) - anchor) <= SAME_STATION_M
def fill_structure_coordinates(
items: Iterable[Any],
xy: Sequence[Sequence[float]],
@@ -51,7 +66,9 @@ def fill_structure_coordinates(
"""구조물 x · y 를 노선 위 기준 자리(`chainage_m`)로 채운다(제자리) · 채운 수.
`previous` = 저장 전 정본 {structure_id: 항목} — 자리(누가거리 셋)가 그대로면 옛 좌표를 지킨다
(노선이 바뀌어 멀어진 땅 자리를 다음 저장이 노선 위로 덮지 않게). `only_missing` = 읽을 때.
(노선이 바뀌어 멀어진 땅 자리를 다음 저장이 노선 위로 덮지 않게). 보낸 좌표가 그 누가거리를
낳으면(지금 노선 따라감 = 제자리) 그 좌표를 살린다(410 · 옮긴 자리 · 따라간 땅 자리) — 낡은
좌표(누가거리만 옮김)는 노선 위로. TS 짝 = `placeOnLine`. `only_missing` = 읽을 때.
"""
line = _line(xy)
if line is None:
@@ -68,6 +85,8 @@ def fill_structure_coordinates(
if old is not None and _positions(old) == _positions(item) and old.x is not None:
item.x, item.y = old.x, old.y
continue
if has_xy and _holds_station(item, line):
continue
point = line.interpolate(float(anchor))
item.x, item.y = round(float(point.x), 4), round(float(point.y), 4)
filled += 1
+28 -7
View File
@@ -9,8 +9,10 @@
* - `followStructures` ↔ 파이썬 `common_util_route_follow.follow_structures`(+ 0 ~ 연장 자름 ·
* 노선과 거리 · `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘으면 「노선에서 멀어짐」 · 버리지 않음).
* - `projectOnPolyline` = shapely `LineString.project`(가장 가까운 첫 선분) · `distance`.
* - `placeOnLine` · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 전 자리로 ·
* 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u).
* - `placeOnLine` 좌표 낡음 판정 ↔ 파이썬 `fill_structure_coordinates`(좌표가 그 누가거리를 낳으면 살림 ·
* 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py.
* - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기
* 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u).
* 거울 시험 = resources/tester/test_344_2_route_pairs.py.
* ========================================================================== */
@@ -53,18 +55,35 @@ export function projectOnPolyline(
return best && walked > 0 ? { ...best, length_m: walked } : null;
}
/** 좌표 없는 관 · 구조물(세션 초안 = 정본 PUT 꼴 · 좌표 뺌)에 자리를 붙인 사본 — 같은 id · 같은 누가거리인
* `saved`(정본 · 좌표 있음)가 있으면 그 좌표(안 옮긴 것 = 서버 따라감과 같은 자리) · 없으면 그 누가거리의
* `line` 위 자리(연장 밖은 끝 선분 위로 자름). 좌표가 있거나 누가거리가 없으면 그대로(같은 객체). */
/** 좌표가 그 누가거리를 낳는가 — `line` 에서 따라감(같은 선 = 제자리)이 누가거리를 안 옮김(410 · 파이썬
* `fill_structure_coordinates` 같은 규칙). 아니면 좌표가 낡음(마커 · 방향키 · 폼으로 누가거리만 옮김). */
const holdsStation = (
item: FollowingStructure,
line: ReadonlyArray<XY>,
intervalM: number | null,
): boolean => {
const [moved] = followStructures([item], line, Infinity, intervalM);
return (
moved.drift_m === null || Math.abs(moved.item.chainage_m! - item.chainage_m!) <= SAME_STATION_M
);
};
/** 관 · 구조물에 자리를 붙인 사본 — 좌표가 그 누가거리를 낳으면 그대로(같은 객체 · 410) · 좌표 없음(세션 관
* 초안 = 정본 PUT 꼴 · 좌표 뺌)이면 같은 id · 같은 누가거리인 `saved`(정본 · 앞 따라감 · 좌표 있음)의 좌표(안
* 옮긴 것 = 서버 따라감과 같은 자리) · 그 밖(옮김 · 좌표 낡음)은 그 누가거리의 `line` 위 자리(연장 밖은 끝 선분
* 위로 자름 = 옮긴 자리가 새 기준). 누가거리가 없으면 그대로. `intervalM` = site 맞춤 간격(`followStructures`). */
export function placeOnLine<S extends FollowingStructure & { id?: string | null }>(
items: ReadonlyArray<S>,
line: ReadonlyArray<XY>,
saved: ReadonlyArray<FollowingStructure & { id?: string | null }> = [],
intervalM: number | null = null,
): S[] {
const byId = new Map(saved.map((item) => [item.id, item]));
return items.map((item) => {
if ((item.x != null && item.y != null) || item.chainage_m == null) return item;
const was = item.id ? byId.get(item.id) : undefined;
if (item.chainage_m == null) return item;
const placed = item.x != null && item.y != null;
if (placed && holdsStation(item, line, intervalM)) return item;
const was = !placed && item.id ? byId.get(item.id) : undefined;
if (
was?.x != null &&
was.y != null &&
@@ -196,11 +215,13 @@ export const placedOnLine = (
list: unknown,
line: ReadonlyArray<[number, number]>,
saved: FollowingStructure[],
intervalM: number | null = null,
): unknown =>
Array.isArray(list)
? placeOnLine(
list as FollowingStructure[],
line.map(([x, y]) => ({ x, y })),
saved,
intervalM,
)
: list;
+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}`;
@@ -128,3 +128,12 @@
- 합친곡선의 경우에는 측점을 현재 로직에서 못 찾을리 없음. 측점 위치로 이동되기 때문, 이떄 곡선의 길이가 길어져 여러군데의 측점을 있다면 이때는 가운데 지점만 신경쓰면 됨.
- 평면노선에서 원래라는 의미가 없음. 한도를 정한 이유가 뭐임?
- 직선은 존재해야함.
## 요구 252 (10-18 · 251 답 뒤 사용자 · 「신규계획서를 만들기 위해 너가 검토를 많이 해볼것」)
1. 생성된 프로젝트는 개별 워크트리의 에이전트들이 검증하기 위함. 아마 프로그램의 내부 관리 파일중에 워크트리의 에이전트들이 프로젝트 ID를 확인하기 위한 문서가 있다면 ID업데이트 없다면 그냥 무시
2. 평면도의 R이 일반 측점이 아닌경우 곡선 측점을 추가 되어야함.
3. 평면도의 R을 변경할 경우 노선을 기준으로 높은 숫자의 측점 번호들이 자동으로 측점을 맞추는 로직이 들어가 있음? 없다면 제어로직을 재활용하여 수정 필요
4. 구조물 측점의 경우 평면도에 마커를 노선상의 표현들 중 상위에 배치해 주고 선택된 이후에 다른 지점을 누르면 선택취소되게 변경
5. 구조물 측점의 경우 노선의 번호를 유지하게 했는데 이걸 생각해보니 구조물의 위치는 최대한 등고선의 좌표를 기준으로 최대한 지정한 위치를 지키게 하는 맞겠어.
6. 구조물 측점을 앞 측점의 노선의 번호를 이동할때 신규로 구조물 측점이 생성이 되고 우측 횡단내용이 안나오고 있어. 확인해줘.
@@ -0,0 +1,100 @@
# 요구 252 분석 — 곡선 측점 · R 바꿈 뒤 측점 맞춤 · 구조물 측점 마커
## 1. 검증 프로젝트 id (252-1)
- 에이전트가 프로젝트 id 를 찾는 문서 = `OWNERS.md` 표(서브 여덟 폴더 = 메인 파일 링크 · git 밖 · 데스크탑은 시놀로지) → 새 id 13 개로 바꿈(10-18).
- 옛 id 를 쓰는 곳: 지난 기록(`docs/raw/plans` · `docs/raw/조사` · `요구_10xx`) = 이력이라 그대로 · `resources/tester` 일부 시험 · 견본(옛 프로젝트 자료에 맞춘 것) = 배정 프로젝트 없음으로 건너뜀(9 skipped) — 사용자 말 「없다면 무시」에 따라 손대지 않음.
## 2. 곡선 측점 (252-2)
지금:
- 덧 측점(이형 측점)을 심는 것 = 관 · 구조물(횡단도 부품 `anchor` · `span3`) · 사용자 덧 측점뿐 — 곡선 점(BC · MC · EC)은 어디서도 측점이 안 됨(`common_util_structure_station.py:121-216` ↔ `.ts:237-307`).
- 곡선 관련 측점 = 요구 224-2 「호 사이 측점은 호 가운데로」 하나 — [측점 스냅] 켬일 때 호 안 일반측점 하나를 MC 로 **옮김**(`B05_Profile_Engine_Sections_Core.py:298-324` `snap_curve_stations` ↔ `logic/B05_Profile_UI_RouteEdit_Frame.ts:165-183`) · 옮긴 측점은 일반측점 이름 그대로 · 원래 자리(20 m 배수)는 사라짐 · 측점 수 그대로(`test_224_2:239-244`).
- 빈틈:
- 스냅 끔 = MC 측점 없음.
- 호가 짧아 안에 일반측점이 없으면 = MC 측점 없음.
- 스냅 간격 0.1 · 1 · 10 m 또는 맞춤 실패 = 일반측점(예 No.5 = 100 m)이 107 m 로 옮겨져 20 m 격자가 깨짐.
정함(브레인):
- 224-2 「호 안 측점은 호 가운데로」 그대로 — 스냅 켬 · 호 안에 일반측점이 있으면 지금처럼 그 측점을 MC 로 옮김.
- 그 밖에 MC 에 측점이 없으면(스냅 끔 · 호가 짧아 안에 일반측점 없음) MC 자리에 **곡선 측점**(새 종류 `curve`) 하나를 더함 · MC 가 일반측점 값(스냅 간격 자릿수로 반올림 · 20 m 배수)과 같으면 더하지 않음.
- 곡선 측점 값 = 스냅 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 · 스냅 끔 = 정수 m · 390-5 규칙).
- BC · EC 는 더하지 않음(지식DB `산림과임업기술(임도)/2. 임도/4. 노선측량.md:144` = BC · MC · EC 보조측점 · 사용자 규칙은 MC 하나 · 곡선마다 둘씩 늘면 횡단 계산이 크게 늚) — 사용자가 원하면 같은 길로 더함.
- 이름 = 「No.5+7 (MC)」 꼴 · 열쇠 = `curve:<누가거리>`(`common_util_station_key` 짝) · 종단 · 횡단(B06 카드) · 평면 눈금 모두 한 목록으로.
바꿀 곳:
- 서버 `generate_sections`(Core.py:388-415) ↔ TS `buildStations`(`logic/B05_Profile_Sections_Stations.ts:281-311` · `gridIndices` 가 곡선 측점에 격자 번호를 주지 않게).
- 종류 · 열쇠: `StationKind`(`A00_Common/design/logic/design_pipeline_types.ts:156`) · `stationKey`(Sections_Stations.ts:72-82) · `common_util_station_key.py ↔ .ts`.
- `irregular` 로 두지 않음 — `_merge_irregular_into_longitudinal`(Router_Confirm.py:327) 이 지우고 `oneStationList` · `setGeneralStations` 가 걸러냄 · B06 `_stale_marks`(B06_Section_Router_Stations.py:153)가 낡음으로 셈.
- B06 종류 갈래: `B06_Section_Station_Keys.ts:118` · `B06_Section_Section_Drafts.ts:212` · `logic/B06_Section_Station_Swap.ts:145` · `B06_Section_UI_Longitudinal.ts:411`.
- 평면 눈금: `UI_RouteEdit_SnapStep.ts:93-100`(스냅 끔 = null) · `UI_RouteEdit_Render.ts:476-486`.
- 거울 시험: `test_344_3_pipeline_stations_mirror` · `test_344_3_station_key_mirror` · `test_224_2_station_snap`(옮김 그대로 · 더함 경우 추가) · `test_390_5_snap_step_ops`.
- 곡선 측점은 새 열쇠라 종단 · 횡단 초안 없음(자동 설계) · 노선이 바뀌면 MC 따라 새로 생김(옛 곡선 측점 초안은 400 이월 규칙 = 옆 1.0 m 안이면 이어받음).
## 3. R 바꿈 뒤 뒤쪽 측점 맞춤 (252-3)
답: **있음** — 반지름 · 곡선 길이 입력(`UI_RouteEdit_CurveBar.ts:66-87`) → `applyEdit`(`UI_RouteEdit.ts:435-445`) → `fitCurveStations(…, k-1)`(`StationFit.ts:142-177`) = 교점 끌기 · 방향키와 같은 함수 · 고친 곡선부터 끝 곡선까지 MC 를 다시 측점에 맞춤(뒤 교점을 들어오는 접선 따라 밂).
빈틈:
- 조용히 빠짐 — 고정 교점(`apexLock`) · 풀이 실패(기울기 0.2 아래 · 10 걸음 안 수렴) · 곡선 한계 새로 깨짐(`shortfallCrossed`) · 앞 곡선 MC 가 tol/5 넘게 밀림 → 그 곡선은 말없이 「측점 밖」 개수로만(StationFit.ts:159-172) · 큰 R 은 접선이 직선 절반에 막혀 이 경우가 잦음.
- 상태줄에 「함께 밀린 교점」 보고 없음(방향키만 `KeyNudge.ts:63-96`).
- 입력 중 미리보기 없음(`change` 때만 · Label.ts:243).
- 시험 없음 — R 바꾼 뒤 뒤쪽 MC(`helper_398_2` 는 고친 곡선만).
정함(브레인):
- 뒤쪽 맞춤은 지금 함수 그대로 씀(재활용) · 고칠 것 = ① R · 곡선 길이 입력 뒤 상태줄에 「실제 바뀜 · 함께 밀린 교점 N곳 · 못 맞춘 곡선 No.…」(KeyNudge 재사용) ② 못 맞춘 곡선은 252-2 곡선 측점으로 자연히 측점이 생김 ③ 시험 = R 키운 뒤 뒤쪽 곡선 MC 가 측점 위(측점 밖 개수 안 늘어남).
- 풀이 실패 줄이기(접선 절반 막힘)는 이번에 안 함 — 398-4 접선 규칙 그대로(251).
## 4. 구조물 측점 마커 — 맨 위 · 다른 곳 누르면 고름 풀림 (252-4)
지금(`B05_Profile_UI_RouteEdit_Render.ts:155-323` 한 캔버스 차례):
- 마커(`drawStructureMarks` :258) 위에 교점 원 · 삼각 · 곡선 손잡이 · 곡선 3점 · 마우스 올림 고리(:260-321)가 그려짐 → 마커가 가려짐.
- 누름 차례(`UI_RouteEdit_Pointer.ts:198-284`) = 교점(12 px) → 곡선 손잡이(14 px) → 마커(10 px · :242) → 측점 눈금 → 호 → 등고선 · 마우스 올림 · 커서도 교점 먼저(:361-374).
- 고름 풀기 없음 — 마커 고름 = 공용 세션 `structure-pick`(`A00_Common/design/design_selection.ts:112-190`) · 교점 · 손잡이 · 호 · 빈 곳 · 등고선을 눌러도 안 풀림 · Esc 없음.
정함(브레인):
- 그리기 = 마커를 맨 마지막(마우스 올림 뒤 · `context.restore()` 앞) · 누름 · 마우스 올림 · 커서 = 마커 먼저(측정 · Shift 다음) · 마커를 누르면 고른 교점 풂(마커 방향키가 교점 고름 때 막힘 · Marks:358).
- 마커 아닌 곳을 누르면(교점 · 손잡이 · 호 · 눈금 · 빈 곳 · 등고선) 마커 고름 풀림 · Esc 도 풂 · 오른쪽 횡단 판은 마지막 측점 그대로(비우지 않음 · `PLAN_PICK_FROM`).
- 시험 `test_399_plan_structure_marks.py:73-86`(지금 차례를 잠근 것) 새 차례로 고쳐 씀.
## 5. 구조물 자리 = 등고선 좌표(x · y) 지킴 (252-5)
지금:
- 규칙 · 코드는 이미 좌표 따라감 — 초안(`A00_Common/design/design_pipeline_page.ts:312-324` → `common_util_route_follow.ts:59-192`) · 저장(`B05_Profile_Route_Save.py:383-387` → `common_util_route_follow.py:77-114` · 관 `common_util_drainage_pipes.py:271-297`).
- 그런데 사용자가 본 「번호 유지」 = 흠 둘이 만든 것:
- 마커로 옮긴 구조물은 누가거리만 바뀌고 x · y 는 옛 값(`B05_Profile_UI_Structures_Snap.ts:27-39` `movedStructure`) → 다음 교점 · R 고침 때 옛 자리로 튀어 돌아감.
- 배수 편집기(`B05_Profile_UI_Drainage_Panel.ts:150-153 · 405-408` `rememberPipes`)가 관 하나만 고쳐도 모든 관을 옛 노선 누가거리로 다시 써서 따라감 결과를 버림(PipePage:603) → 관 번호가 그대로 남음.
- 저장 x · y 채움(`B05_Profile_Structures_Router.py:356-363`)은 누가거리 · 시종점이 그대로일 때만 옛 x · y 유지 · 아니면 누가거리로 다시 셈.
정함(브레인):
- 구조물 · 관 자리 = 처음 놓은(또는 마커로 옮긴) x · y · 노선이 바뀌면 그 x · y 를 새 노선에 투영해 누가거리를 다시 매김(정수 m 반올림 = 사용자가 말한 미세 이동) · 측점 번호는 따라 바뀜.
- 마커로 옮기면 그 자리 x · y 를 새로 적음(노선 위 점) — 사용자가 고른 새 자리가 기준.
- 배수 편집기는 따라간 누가거리를 받아 씀(옛 값으로 덮지 않음) · 관 초안에 x · y 실음.
- 사토장 · 토취장(측점 안 심는 「자리」 종류)은 지금처럼 가까운 일반측점에 붙음.
- 서버 저장도 같은 규칙(x · y 기준 · 파이썬 짝 `common_util_route_follow` · 거울 시험).
## 6. 마커 옮기면 새 측점 · 오른쪽 횡단 빔 (252-6)
원인(코드로 확인):
- **A 오른쪽 횡단 빔(확실)** — 구조물 · 관을 고르면 `Cross.follow` 가 `watching.structure = true` → `redraw()` 가 바로 돌아감(`B05_Profile_UI_RouteEdit_Cross.ts:272 · 343-347 · 394-395`) = 382-2 때 「구조물 고름 = 마름모만」 규칙의 남은 자리 · 399 가 마름모를 지우며 이 규칙을 남김 · 구조물 옮긴 뒤 다시 고름 없음(`B05_Profile_UI_Structures_Panel.ts:675-682`).
- **B 앞 관을 지나 옮기면 새 관(코드로 확인 · 화면 재현 필요)** — 옛 · 새 관 목록을 **정렬 차례로 짝지음**(`A00_Common/design/design_view_profile.ts:122-127` `shownPipes`) → 옮긴 관이 이웃 id 를 받고 이웃은 새 `pipe-sN`(`design_pipe_ids.ts:35-75`) → 새 알약 · 마커 · 이형 측점 · `followedMarks`(`logic/B05_Profile_Structure_Follow.ts:38-45` · ponytail 주석) · `followPipeReports`(`logic/B05_Profile_Pipeline_Follow.ts:22-24`)도 차례 짝.
- **C 노선 고친 초안에서 관 옮김** — 옮김은 따라간 누가거리로 관을 찾는데 배수 편집기는 옛 값(0.51 m 넘게 어긋나면 옮김이 말없이 안 됨 · 마커는 없는 자리 `to` 에 남음 = 새 측점처럼 보임 · Drain:572-581 · Marks:125-136) — 5장 고침으로 같이 풀림.
- **D B06 열쇠 갈래(추정 · 화면 확인)** — 서버 관 id(`p8017`)를 `pipe-` 머리로 가려 `structure:` 열쇠로 셈(`B06_Section_Station_Keys.ts:66-76` · `B06_Section_Section_Drafts.ts:184-185`) vs 파이프라인 `pipe:p8017`(`logic/B05_Profile_Sections_Stations.ts:297-307`) → 옮긴 관이 「새로 생긴 측점 · 계획고 0」(`logic/B06_Section_Station_Swap.ts:124-165`).
정함(브레인):
- 오른쪽 판 = 구조물 · 관을 골라도 그 측점 횡단을 그림(일반측점과 같은 판 · 구조물 측점 횡단).
- 관 · 구조물 짝은 늘 id 로(정렬 차례 짝 걷음) · 옮겨도 id · 알약 · 측점 열쇠 그대로.
- B06 열쇠 갈래를 파이프라인과 같은 규칙으로(서버 관 id 도 `pipe:`).
- 끝 확인 = 사용자 길 그대로: 관 · 구조물 마커를 앞 측점 · 앞 관을 지나 옮김(노선 고친 초안 · 안 고친 상태 둘) → 측점 수 그대로 · 알약 수 그대로 · 오른쪽 횡단 그림 있음 · B06 카드 계획고 0 아님.
## 7. 나눔
| 절 | 일 | 창 | 파일 경계 |
|---|---|---|---|
| 406 | 곡선 합치기(진행 중) | sub4 | 추천 엔진 |
| 407 | 곡선 측점(252-2) | sub6 | 측점 목록 짝(Core.py · Sections_Stations.ts · station_key · StationKind) · B06 종류 갈래(Station_Keys.ts:118 · Section_Drafts.ts:212 · Station_Swap.ts:145 · UI_Longitudinal.ts:411) · 평면 눈금(SnapStep · Render 눈금) |
| 408 | R 바꿈 뒤 보고 · 시험(252-3) | sub1 | CurveBar · KeyNudge · StationFit 시험 |
| 409 · 411-A | 마커 맨 위 · 고름 풀기 · 오른쪽 판(252-4 · 6A) | sub3 | Render(마커 차례) · Pointer · UI StructureMarks · Cross · Cross_Card |
| 410 · 411-B~D | 좌표 따라감 · id 짝 · B06 열쇠(252-5 · 6B~D) | sub7 | design_view_profile · design_pipe_ids · Structures_Snap · Drainage_Panel · Structure_Follow · Pipeline_Follow · route_follow 짝 · B06 Station_Keys.ts:66-76 · Section_Drafts.ts:184-185 |
- 겹치는 파일(B06 `Station_Keys.ts` · `Section_Drafts.ts`) = 407(종류 갈래 곡선 추가) · 411-D(관 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));
+50
View File
@@ -0,0 +1,50 @@
/* 410 좌표 따라감 거울 — 진짜 TS(`common_util_route_follow`)를 Node 로 돌림.
* 입력 argv[2] JSON = { lines: [L1, L2, ...], structures, pipes, followed } · 표준출력 JSON 한 벌.
* 차례(구조물) = L1 에서 자리 붙임(placeOnLine) → L2 따라감 → L2 에서 자리 붙임 → L3 따라감 …
* 관 = 좌표 없는 초안 + 앞 따라감(followed) → L1 자리 붙임 → L2 투영.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
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} (${file})`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const catalog = require("./helper_322_catalog_module.cjs");
const station = loadTs("common_util/common_util_structure_station.ts", {
"./common_util_structure_catalog": catalog,
});
const follow = loadTs("common_util/common_util_route_follow.ts", {
"./common_util_structure_station": station,
});
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const lines = input.lines.map((line) => line.map(([x, y]) => ({ x, y })));
const steps = [];
let items = input.structures;
for (let i = 0; i + 1 < lines.length; i += 1) {
const placed = follow.placeOnLine(items, lines[i], [], input.interval);
items = follow
.followStructures(placed, lines[i + 1], undefined, input.interval)
.map((moved) => moved.item);
steps.push({ placed, followed: items });
}
const pipesPlaced = follow.placeOnLine(input.pipes, lines[0], input.followed);
const out = {
steps,
pipesPlaced,
pipesFollowed: follow.projectPipePoints(pipesPlaced, lines[1]),
};
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));
+20
View File
@@ -0,0 +1,20 @@
/* 411-B · 411-D 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름.
* 관 짝(id) · 관 세션 id 장부 옮김 · 알약 따라감 짝 · 배수 편집기 따라감 · B06 측점 열쇠 갈래를 화면 · 서버 없이 잰다.
*/
export { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow";
export { followedMarks } from "../../B05_Profile/logic/B05_Profile_Structure_Follow";
export {
movePipeId,
pipeIdNear,
placePipeIds,
prunePipeIds,
pipeChainageOf,
pipeIdAt,
usePipeIds,
} from "../../A00_Common/design/design_pipe_ids";
export { stationKeysOf, useStationOwners } from "../../B06_Section/B06_Section_Station_Keys";
export { followEditorPipes } from "../../B05_Profile/B05_Profile_UI_Drainage_Parts";
export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility";
export { planStations } from "../../common_util/common_util_structure_station";
export { createPipeEditor } from "../../B05_Profile/B05_Profile_UI_Drainage_Pipes";
export { stationKey as pipelineStationKey } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
@@ -0,0 +1,183 @@
/* 411-B · 411-D 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_411_b_entry.ts)을 부르고 결과를 JSON 한 벌로.
* node helper_411_b_pipe_id_pairing.mjs <묶음.mjs>
* 사용자 길: 관 p2 를 앞 관 p1 을 지나 옮김(150 → 80) · 노선 따라감이 두 관을 함께 밂.
*/
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
const m = await import(pathToFileURL(process.argv[2]).href);
const out = {};
const P = "p411";
m.usePipeIds(P);
/* 1. 알림 목록 ↔ 가게 관 초안 짝 = id — 옮겨 차례가 뒤바뀌어도 그 관 */
const reports = [
{ id: "p10000", chainage_m: 100, facility: "pipe" },
{ id: "p15000", chainage_m: 150, facility: "pipe", start_m: 145, end_m: 155 },
];
const draft = [
{ id: "p15000", chainage_m: 80 },
{ id: "p10000", chainage_m: 100 },
];
out.reports = m
.followPipeReports(reports, draft)
.map((r) => [r.id, r.chainage_m, r.start_m ?? null]);
/* 2. 세션 id 장부 — 끌어 옮김(followMovedPipe 길) · 그 사이 옛 측점 열쇠가 옛 자리에 헛 id 를 지음 →
* 화면 목록 맞춤(shownPipes = 관 고정 id 가 쥔 세션 id 를 새 자리로 · placePipeIds) */
const idA = m.pipeIdAt(100, P);
const idB = m.pipeIdAt(150, P);
const sessionIds = new Map([
["p10000", idA],
["p15000", idB],
]);
m.movePipeId(150, 80, P); // 끌기 = 옮긴 관 id 그대로
const stray = m.pipeIdAt(150, P); // 옛 측점 열쇠가 옛 자리를 물음
const place = (shown, project) =>
m.placePipeIds(
shown.map((r) => [sessionIds.get(r.id), r.chainage_m]),
project,
);
place(m.followPipeReports(reports, draft), P);
out.ids = { idA, idB, stray, at80: m.pipeIdAt(80, P), at100: m.pipeIdAt(100, P) };
/* 2-1. 되돌림 — 관이 옛 자리로 돌아감 · 그 자리에 헛 id 가 먼저 있어도 그 관 id(헛 기록은 뺌) */
place(
[
{ id: "p15000", chainage_m: 150 },
{ id: "p10000", chainage_m: 100 },
],
P,
);
out.back = { at150: m.pipeIdAt(150, P), strayAt: m.pipeChainageOf(stray, P) };
/* 3. 노선 따라감이 두 관을 함께 밂(앞 관이 뒤 관 옛 자리로) — 기록 안 잃음 · 헛 id 안 생김 */
const Q = "q411";
const q1 = m.pipeIdAt(100, Q);
const q2 = m.pipeIdAt(103, Q);
m.placePipeIds(
[
[q1, 103],
[q2, 106],
],
Q,
);
out.batch = {
q1,
q2,
at103: m.pipeIdAt(103, Q),
at106: m.pipeIdAt(106, Q),
next: m.pipeIdAt(500, Q),
};
/* 4. 알약 따라감 — 알약이 이미 따라간 자리면 같은 자리 짝(차례 뒤바뀜에도 그 관) */
const marks = [
{ structure_id: idB, chainage_m: 80 },
{ structure_id: idA, chainage_m: 100 },
{ structure_id: "st-1", chainage_m: 40 },
];
out.marks = m
.followedMarks(marks, [{ structure_id: "st-1", chainage_m: 42 }], draft)
.map((mk) => [mk.structure_id, mk.chainage_m]);
/* 5. 배수 편집기 — 따라간 관 자리(id 짝)를 받음 · 시설 속성 · 고름도 따라감 */
const editor = m.createPipeEditor(() => {});
editor.setPipes([
{ chainage_m: 100, reason: "stream", id: "p10000" },
{ chainage_m: 150, reason: "confirmed", id: "p15000" },
]);
editor.select(1);
const facilities = m.createFacilityStore();
facilities.set(150, { facility: "ford", options: { 월류높이: 0.3 } });
m.followEditorPipes(editor, facilities, [
{ id: "p10000", chainage_m: 103 },
{ id: "p15000", chainage_m: 98 },
]);
out.editor = {
pipes: editor.pipes().map((p) => [p.id, p.chainage_m, p.reason]),
selected: editor.pipes()[editor.selected()]?.id ?? null,
ford98: facilities.get(98)?.facility ?? null,
at150: facilities.get(150),
};
m.followEditorPipes(editor, facilities, [{ id: "p10000", chainage_m: 103 }]); // 같은 값 = 그대로
out.editorSame = editor.pipes().map((p) => p.chainage_m);
/* 5-1. 막 놓은 관 — 초안엔 id 없음 · 편집기는 서버 id(재계산 응답) → id 없는 것끼리 차례 짝 */
const fresh = m.createPipeEditor(() => {});
fresh.setPipes([
{ chainage_m: 120, reason: "confirmed", id: "p12000" },
{ chainage_m: 351, reason: "stream", id: "p35100" },
]);
m.followEditorPipes(fresh, m.createFacilityStore(), [
{ chainage_m: 120 },
{ id: "p35100", chainage_m: 353 },
]);
const fresh2 = m.createPipeEditor(() => {});
fresh2.setPipes([
{ chainage_m: 200, reason: "confirmed", id: "p20000" },
{ chainage_m: 351, reason: "stream", id: "p35100" },
]);
m.followEditorPipes(fresh2, m.createFacilityStore(), [
{ chainage_m: 204 },
{ id: "p35100", chainage_m: 353 },
]);
out.fresh = [fresh.pipes(), fresh2.pipes()].map((list) => list.map((p) => [p.id, p.chainage_m]));
/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) */
const planned = [
{ chainage_m: 80.17, owners: ["p8017"], role: "anchor" },
{ chainage_m: 52.5, owners: ["st-1"], role: "anchor" },
];
const sections = [
{ chainage_m: 52.5, kind: "irregular" },
{ chainage_m: 80.17, kind: "irregular" },
];
m.useStationOwners(P, planned, [{ id: "p8017", chainage_m: 80.17 }]);
out.keys = m.stationKeysOf(P, sections, 20).map((k) => k.key);
m.useStationOwners(P, planned);
out.keysNoPipes = m.stationKeysOf(P, sections, 20).map((k) => k.key);
out.pipelineKey = m.pipelineStationKey("pipe", "p8017");
/* 7. 411-E — 관 계획을 아는 동안 옛 자리 관 측점(옮겨 간 자리)은 세션 id 를 안 지음 · 헛 id 정리 */
const E = "e411";
m.usePipeIds(E);
const e1 = m.pipeIdAt(80, E); // 옮긴 관(새 자리)
const bookSize = () => JSON.parse(sessionStorage.getItem(`aislo:draft:pipe-ids:${E}`)).ids.length;
m.useStationOwners(E, [{ chainage_m: 80, owners: ["p15000"], role: "anchor" }], [{ id: "p15000" }]);
const staleSections = [
{ chainage_m: 80, kind: "irregular", structure: "파형강관 D800", culvert: {} },
{ chainage_m: 150, kind: "irregular", structure: "파형강관 D800", culvert: {} }, // 옛 자리
];
out.staleKeys = m.stationKeysOf(E, staleSections, 20).map((k) => k.key);
out.staleBook = bookSize();
m.useStationOwners(E, []); // 관 계획 모름(초안 없음) = 옛 갈래 그대로 지음
out.legacyKey = m.stationKeysOf(E, [staleSections[1]], 20)[0].key;
out.legacyBook = bookSize();
m.prunePipeIds([80], E); // 지금 관 자리만 남김
out.pruned = { size: bookSize(), at80: m.pipeIdAt(80, E), e1 };
m.usePipeIds(P);
process.stdout.write(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
+203
View File
@@ -0,0 +1,203 @@
# -*- coding: utf-8 -*-
"""410 구조물 · 관 자리 = 등고선 좌표(x · y) 지킴 — 교점 옮김 · R 바꿈 뒤 좌표 그대로 ·
누가거리 다시 매김.
거울(파이썬 = 서버 정본 · TS = 브라우저 즉시 · 같은 값):
① 좌표 낡음 판정 — `common_util_route_follow.fill_structure_coordinates` ↔ TS `placeOnLine`
(좌표가 그 누가거리를 낳으면 살림 · 누가거리만 옮긴 구조물 = 그 누가거리의 노선 위 자리).
② 따라감 — `follow_structures` ↔ `followStructures`(L1 → L2 교점 옮김 → L3 곡선 바꿈).
③ 관 — 좌표 없는 초안 관은 앞 따라감(같은 id · 같은 자리)의 좌표 · 옮긴 관은 노선 위 자리.
핵심: 마커로 옮긴 구조물은 다음 노선 고침에 옛 자리로 안 튐 · 안 옮긴 구조물 · 관은 좌표 그대로.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from shapely.geometry import LineString, Point
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402
from common_util.common_util_route_follow import ( # noqa: E402
fill_structure_coordinates,
follow_structures,
)
from common_util.common_util_route_geometry import RouteVertex # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
L1 = [(0.0, 0.0), (100.0, 0.0), (200.0, 50.0)]
L2 = [(0.0, 0.0), (100.0, 12.0), (200.0, 50.0)] # 교점 옮김
L3 = [(0.0, 0.0), (85.0, 4.0), (100.0, 10.0), (115.0, 18.0), (200.0, 50.0)] # 곡선(R) 바꿈
LINES = [L1, L2, L3]
INTERVAL = 20.0
def _at(line, chainage, offset=0.0):
"""노선 위 누가거리 자리 + 왼쪽 법선 offset(m)."""
geo = LineString(line)
p = geo.interpolate(chainage)
q = geo.interpolate(min(chainage + 0.01, geo.length))
dx, dy = q.x - p.x, q.y - p.y
norm = (dx * dx + dy * dy) ** 0.5 or 1.0
return p.x - dy / norm * offset, p.y + dx / norm * offset
def _structure(sid, placement, chainage, xy, start=None, end=None):
return {
"structure_id": sid,
"placement": placement,
"chainage_m": chainage,
"start_m": start,
"end_m": end,
"x": xy[0],
"y": xy[1],
}
# S1 = 노선 밖 5 m 땅 자리(안 옮김) · S2 = 140 m 에 놓았다 마커로 60 m 로 옮김(좌표 옛 값) ·
# S3 = 구간(안 옮김 · 3 m 밖) · S4 = 사토장(측점 80 · 땅 자리 84 m) ·
# S5 = 구간을 폼으로 옮김(좌표 옛 값)
STRUCTURES = [
_structure("S1", "point", 50.0, _at(L1, 50.0, 5.0)),
_structure("S2", "point", 60.0, _at(L1, 140.0)),
_structure("S3", "interval", 120.0, _at(L1, 120.0, -3.0), 110.0, 135.0),
_structure("S4", "site", 80.0, _at(L1, 84.0, 2.0)),
_structure("S5", "interval", 30.0, _at(L1, 170.0), 25.0, 40.0),
]
# 관 — p1 = 앞 따라감 그대로(좌표 없는 초안 · 같은 자리) · p2 = 초안에서 옮김(앞 따라감과 자리 다름)
FOLLOWED = [
{"id": "p1", "chainage_m": 70.0, "x": _at(L1, 70.0, 1.5)[0], "y": _at(L1, 70.0, 1.5)[1]},
{"id": "p2", "chainage_m": 150.0, "x": _at(L1, 150.0)[0], "y": _at(L1, 150.0)[1]},
]
PIPES = [{"id": "p1", "chainage_m": 70.0}, {"id": "p2", "chainage_m": 30.0}]
@pytest.fixture(scope="module")
def ts(tmp_path_factory) -> dict:
source = tmp_path_factory.mktemp("s410") / "in.json"
source.write_text(
json.dumps(
{
"lines": LINES,
"structures": STRUCTURES,
"interval": INTERVAL,
"pipes": PIPES,
"followed": FOLLOWED,
}
),
encoding="utf-8",
)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_410_follow_xy.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def py() -> list[dict]:
"""서버 차례 — 저장(좌표 채움 · L1) → 노선 저장(따라감 · L2) → 저장(L2) → 노선 저장(L3)."""
items = [SimpleNamespace(**item) for item in STRUCTURES]
steps = []
for index in range(len(LINES) - 1):
fill_structure_coordinates(items, LINES[index])
placed = [dict(vars(item)) for item in items]
follow_structures(items, LINES[index + 1])
steps.append({"placed": placed, "followed": [dict(vars(item)) for item in items]})
return steps
def _by_id(rows):
return {row["structure_id"]: row for row in rows}
def _close(a, b, tol=1e-3):
return (a is None and b is None) or abs(float(a) - float(b)) <= tol
@pytest.mark.parametrize("step", [0, 1])
def test_파이썬_TS_같은_값(ts, py, step):
for phase in ("placed", "followed"):
got, want = _by_id(ts["steps"][step][phase]), _by_id(py[step][phase])
for sid, row in want.items():
for key in ("chainage_m", "start_m", "end_m", "x", "y"):
assert _close(got[sid].get(key), row[key]), (step, phase, sid, key)
def test_안_옮긴_구조물_좌표_그대로(py):
for sid in ("S1", "S3", "S4"):
first = _by_id(py[0]["placed"])[sid]
for step in py:
for phase in ("placed", "followed"):
now = _by_id(step[phase])[sid]
assert _close(now["x"], first["x"]) and _close(now["y"], first["y"]), sid
def test_누가거리는_새_노선_투영(py):
for step, line in zip(py, LINES[1:]):
geo = LineString(line)
for sid in ("S1", "S2"):
row = _by_id(step["followed"])[sid]
assert row["chainage_m"] == float(round(geo.project(Point(row["x"], row["y"]))))
# 사토장 = 새 노선 일반 측점 중 가장 가까운 곳
site = _by_id(step["followed"])["S4"]
assert site["chainage_m"] % INTERVAL == 0
def test_마커로_옮긴_구조물은_옛_자리로_안_튐(py):
moved_xy = _at(L1, 60.0)
for step in py:
for phase in ("placed", "followed"):
row = _by_id(step[phase])["S2"]
assert _close(row["x"], moved_xy[0]) and _close(row["y"], moved_xy[1])
assert 50 <= row["chainage_m"] <= 70 # 옛 자리 140 m 근처로 안 돌아감
form = _by_id(py[1]["followed"])["S5"]
assert 20 <= form["chainage_m"] <= 40 and form["end_m"] - form["start_m"] == 15
def test_서버는_보낸_좌표가_누가거리를_낳으면_살림():
# 노선 고친 초안에서 옮긴 구조물 — 옛 정본과 자리가 달라도 보낸 좌표(노선 밖 4 m)가
# 그 누가거리를 낳음
xy = _at(L2, 90.0, 4.0)
item = SimpleNamespace(**_structure("S9", "point", 90.0, xy))
old = SimpleNamespace(**_structure("S9", "point", 140.0, _at(L2, 140.0)))
fill_structure_coordinates([item], L2, previous={"S9": old})
assert (item.x, item.y) == xy
stale = SimpleNamespace(**_structure("S9", "point", 30.0, xy)) # 누가거리만 옮김 = 낡은 좌표
fill_structure_coordinates([stale], L2, previous={"S9": old})
assert _close(stale.x, _at(L2, 30.0)[0]) and _close(stale.y, _at(L2, 30.0)[1])
def test_관_초안은_앞_따라감_좌표_옮긴_관은_노선_위(ts):
placed = {row["id"]: row for row in ts["pipesPlaced"]}
assert _close(placed["p1"]["x"], FOLLOWED[0]["x"]) and _close(
placed["p1"]["y"], FOLLOWED[0]["y"]
)
assert _close(placed["p2"]["x"], _at(L1, 30.0)[0]) and _close(placed["p2"]["y"], 0.0)
vertices = [RouteVertex(x=x, y=y, z=0.0, chainage_m=0.0) for x, y in L2]
points = [
PipePoint(chainage_m=70.0, x=FOLLOWED[0]["x"], y=FOLLOWED[0]["y"], id="p1"),
PipePoint(chainage_m=30.0, x=_at(L1, 30.0)[0], y=0.0, id="p2"),
]
want = {point.id: point.chainage_m for point in project_pipe_points(points, vertices)}
got = {row["id"]: row["chainage_m"] for row in ts["pipesFollowed"]}
assert got == want
@@ -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
@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""411-B · 411-D 관 짝 = id — 앞 관을 지나 옮겨도 알약 · 측점 수 · id 그대로 ·
B06 열쇠 = 계산 묶음과 같음.
사용자 길(요구 252-6): 관 마커를 앞 관을 지나 옮김 → 옛 차례 짝이 이웃 id 를 빼앗고
새 `pipe-sN` 을 지어 새 측점 · 계획고 0 이 생기던 흠. 진짜 TS 를 vite ssr 로
묶어(`helper_411_b_entry.ts`) Node 로 잰다:
① `followPipeReports` 짝 = 관 고정 id
② 세션 id 장부(`movePipeId` 끌기 · `placePipeIds` 화면 맞춤)
③ `followedMarks`
④ 배수 편집기 `followEditorPipes`(410 · 따라간 자리 받음)
⑤ B06 `useStationOwners` · `stationKeysOf`.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b411")
build = subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_411_b_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert build.returncode == 0, build.stderr
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_411_b_entry.js", bundle)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_411_b_pipe_id_pairing.mjs"), str(bundle)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_알림_목록_짝은_id(out):
# 옮긴 관(p15000)이 앞 관 자리를 빼앗지 않음 · 구간도 그 관과 함께 밂
assert sorted(out["reports"]) == [["p10000", 100, None], ["p15000", 80, 75]]
def test_앞_관_지나_옮겨도_세션_id_그대로(out):
ids = out["ids"]
assert ids["at80"] == ids["idB"] # 옮긴 관 = 제 id(옛 자리 헛 id 를 안 받음)
assert ids["at100"] == ids["idA"] # 이웃 관 = 제 id(새 pipe-sN 아님)
def test_되돌려_옛_자리로_가도_그_관_id(out):
assert out["back"]["at150"] == out["ids"]["idB"]
assert out["back"]["strayAt"] is None # 그 자리 헛 기록은 장부에서 빠짐
def test_따라감이_여럿을_함께_밀어도_기록_안_잃음(out):
batch = out["batch"]
assert batch["at103"] == batch["q1"] and batch["at106"] == batch["q2"]
assert batch["next"] == "pipe-s3" # 헛 id 없음 = 알약 · 측점 수 그대로
def test_알약은_그_관_자리(out):
assert out["marks"] == [["pipe-s2", 80], ["pipe-s1", 100], ["st-1", 42]]
def test_배수_편집기는_따라간_자리를_받음(out):
editor = out["editor"]
assert editor["pipes"] == [["p15000", 98, "confirmed"], ["p10000", 103, "stream"]]
assert editor["selected"] == "p15000" # 고름도 그 관
assert editor["ford98"] == "ford" and editor["at150"] is None # 시설 속성도 새 자리
assert out["editorSame"] == [98, 103]
def test_막_놓은_관은_id_없는_것끼리_차례로_따라감(out):
assert out["fresh"] == [
[["p12000", 120], ["p35100", 353]],
[["p20000", 204], ["p35100", 353]],
]
def test_B06_열쇠는_서버_관_id_도_pipe(out):
assert out["keys"] == ["structure:st-1", "pipe:p8017"]
assert out["keys"][1] == out["pipelineKey"] # 계산 묶음 측점 열쇠와 같음
assert out["keysNoPipes"][1] == "structure:p8017" # 관 목록을 안 주면 옛 갈래(대조)
def test_411_E_옛_자리_관_측점은_헛_id_안_지음(out):
assert out["staleKeys"] == ["pipe:p15000", "at:150.00"]
assert out["staleBook"] == 1 # 세션 id 표에 헛 id 0
assert out["legacyKey"] == "pipe:pipe-s2" and out["legacyBook"] == 2 # 초안 없음 = 옛 갈래
def test_411_E_헛_id_정리(out):
assert out["pruned"] == {"size": 1, "at80": out["pruned"]["e1"], "e1": "pipe-s1"}