Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_4' into sub_laptop_3
This commit is contained in:
@@ -571,6 +571,7 @@ function createPagePipeline(
|
||||
intervalM: options.stationIntervalM,
|
||||
types: enabled,
|
||||
stationSnap: routePlan.station_snap ?? false,
|
||||
stationSnapStep: routePlan.station_snap_step,
|
||||
});
|
||||
crossSource = null;
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ export function reuseOutside(
|
||||
|
||||
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 노선 초안 스냅. */
|
||||
export function createStationsStage(
|
||||
options: Omit<StationsOptions, "stationSnap" | "types"> & {
|
||||
options: Omit<StationsOptions, "stationSnap" | "stationSnapStep" | "types"> & {
|
||||
types: () => ReadonlyArray<PlanType>;
|
||||
},
|
||||
): StageFn<"stations"> {
|
||||
@@ -55,7 +55,12 @@ export function createStationsStage(
|
||||
route.curves,
|
||||
(ctx.inputs.pipes as PlanPipe[] | null) ?? [],
|
||||
(ctx.inputs.structures as PlanStructure[] | null) ?? [],
|
||||
{ ...options, types: options.types(), stationSnap: ctx.inputs.route?.stationSnap ?? false },
|
||||
{
|
||||
...options,
|
||||
types: options.types(),
|
||||
stationSnap: ctx.inputs.route?.stationSnap ?? false,
|
||||
stationSnapStep: ctx.inputs.route?.stationSnapStep,
|
||||
},
|
||||
);
|
||||
return reuseOutside(next, ctx.prev, ctx.range);
|
||||
};
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface RouteDraft {
|
||||
curveArc: Array<number | null>;
|
||||
/** [측점 스냅](요구 224-2) — 켜면 호 안 측점 하나를 곡선 중점으로. */
|
||||
stationSnap: boolean;
|
||||
/** 스냅 간격(m · 0.1 · 1 · 10 · null·없음 = 측점 No.) — MC 측점 값 자릿수(390-5). */
|
||||
stationSnapStep?: number | null;
|
||||
/** 분석 지형(`las` · `sheet` · null = 확정 지표면) — ground 단계가 고를 격자. */
|
||||
terrain: string | null;
|
||||
}
|
||||
|
||||
@@ -211,7 +211,8 @@ export interface StationTickOptions {
|
||||
/** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고,
|
||||
* 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */
|
||||
onLabel?: (box: StationLabelBox) => void;
|
||||
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). */
|
||||
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2).
|
||||
* 주면 라벨은 그 값 자릿수로(소수 한 자리까지 · 스냅 간격 0.1 의 MC · 390-5) · 안 주면 정수. */
|
||||
chainages?: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
@@ -287,7 +288,7 @@ export function drawStationTicks(
|
||||
context.strokeStyle = "rgba(40, 40, 40, 0.85)";
|
||||
context.stroke();
|
||||
|
||||
const label = stationLabel(chainage, interval);
|
||||
const label = stationLabel(chainage, interval, options.chainages ? 1 : 0);
|
||||
const width = context.measureText(label).width + 6;
|
||||
const half = width / 2;
|
||||
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
|
||||
|
||||
@@ -117,6 +117,8 @@ export interface RoutePlanResponse {
|
||||
edited: boolean;
|
||||
/** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */
|
||||
station_snap?: boolean;
|
||||
/** 스냅 간격(m · 0.1 · 1 · 10) — 칸 없거나 null = 측점 No.(390-5 · MC 측점 값 자릿수). */
|
||||
station_snap_step?: number | null;
|
||||
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
|
||||
current_initial?: { logic: string; weights: Record<string, number> | null };
|
||||
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
|
||||
|
||||
@@ -90,6 +90,15 @@ def load_station_snap(project_root: Path) -> bool:
|
||||
return _read_design_curve_table(project_root).get("station_snap") is True
|
||||
|
||||
|
||||
def load_station_snap_step(project_root: Path) -> float | None:
|
||||
"""스냅 간격(m) — 곡선표 `"station_snap_step"`(390-5 · [확인] 때 적힘).
|
||||
|
||||
칸 없으면 None(측점 No.).
|
||||
"""
|
||||
step = _read_design_curve_table(project_root).get("station_snap_step")
|
||||
return float(step) if isinstance(step, int | float) and step > 0 else None
|
||||
|
||||
|
||||
def _read_design_curve_table(project_root: Path) -> dict[str, Any]:
|
||||
"""설계가 쓰는 곡선표 파일 통째 — 수정본이 있으면 그 곁 것 · 없거나 못 읽으면 빈 dict."""
|
||||
route_path = planned_route_working_path(project_root)
|
||||
@@ -458,6 +467,7 @@ def run_section_generation(
|
||||
# 종전의 측점별 실측 반경으로 물러선다.
|
||||
design_curves=_load_design_curves(project_root),
|
||||
station_snap=load_station_snap(project_root),
|
||||
station_snap_step=load_station_snap_step(project_root),
|
||||
)
|
||||
|
||||
stage_root = project_root / _STAGE_SUBDIR
|
||||
@@ -573,6 +583,7 @@ def sample_structure_sections(
|
||||
design_curves=_load_design_curves(project_root),
|
||||
# 규칙 측점과 같은 스냅 — 한쪽만 켜면 [저장] 때 옮기기 전 격자(640)가 되살아남.
|
||||
station_snap=load_station_snap(project_root),
|
||||
station_snap_step=load_station_snap_step(project_root),
|
||||
)
|
||||
|
||||
def structural(entry: dict[str, Any]) -> bool:
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
|
||||
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
|
||||
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
|
||||
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔
|
||||
`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험
|
||||
`resources/tester/test_224_2_station_snap.py`.
|
||||
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ·
|
||||
`_mc_scale` ↔ `B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` ·
|
||||
`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` 측점 부분(격자 · 스냅 · 구조물 측점 병합 · 종류 · 방위각) ·
|
||||
`_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `B05_Profile/logic/B05_Profile_Sections_Stations.ts`
|
||||
@@ -288,15 +289,25 @@ def _design_curve_spans(
|
||||
CURVE_STATION_EDGE_M = 0.005
|
||||
|
||||
|
||||
def _mc_scale(step_m: float | None) -> int:
|
||||
"""MC 측점 값 자릿수 배수(390-5) — 스냅 간격 1 m 미만(0.1) = 10(소수 한 자리) · 그 밖 = 1(정수).
|
||||
`mcScale` 짝."""
|
||||
return 10 if step_m is not None and step_m < 1 else 1
|
||||
|
||||
|
||||
def snap_curve_stations(
|
||||
stations: np.ndarray | list[float],
|
||||
spans: list[tuple[float, ...]],
|
||||
step_m: float | None = None,
|
||||
) -> np.ndarray:
|
||||
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 정수 MC
|
||||
(floor(MC+0.5))로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 측점과 같은 m 이면 하나로.
|
||||
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 MC
|
||||
(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른
|
||||
측점과 같은 값이면 하나로.
|
||||
|
||||
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리).
|
||||
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리) · `step_m` = 스냅 간격
|
||||
(0.1 · 1 · 10 · None = 측점 No. · 390-5).
|
||||
"""
|
||||
scale = _mc_scale(step_m)
|
||||
values = [float(value) for value in stations]
|
||||
for span in spans:
|
||||
start, end = float(span[0]), float(span[1])
|
||||
@@ -309,7 +320,7 @@ def snap_curve_stations(
|
||||
if not inside:
|
||||
continue
|
||||
nearest = min(inside, key=lambda index: abs(values[index] - middle))
|
||||
values[nearest] = float(math.floor(middle + 0.5))
|
||||
values[nearest] = math.floor(middle * scale + 0.5) / scale
|
||||
return np.unique(np.asarray(values, dtype=np.float64))
|
||||
|
||||
|
||||
@@ -358,10 +369,12 @@ def generate_sections(
|
||||
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` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) ·
|
||||
`station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.).
|
||||
"""
|
||||
options = options or SectionGenerationOptions()
|
||||
options.validate()
|
||||
@@ -375,7 +388,7 @@ def generate_sections(
|
||||
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
|
||||
spans = _design_curve_spans(points, route_chainage, design_curves or [])
|
||||
if station_snap:
|
||||
station_chainage = snap_curve_stations(station_chainage, spans)
|
||||
station_chainage = snap_curve_stations(station_chainage, spans, station_snap_step)
|
||||
# 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다.
|
||||
regular_rounded = {round(float(value), 6) for value in station_chainage}
|
||||
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
|
||||
|
||||
@@ -6,7 +6,8 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan
|
||||
`station_snap`) +
|
||||
`draft` — 계약 `RouteDraft`(`A00_Common/design/logic/design_pipeline_types.ts`) 그대로
|
||||
`{rev, nodes: [[x, y]…], curveOn: [bool…], curveRadius: [m | null…], curveLock, curveArc,
|
||||
stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것.
|
||||
stationSnap, stationSnapStep, terrain}` · 오면 `vertices` · `station_snap`(· `_step`) ·
|
||||
`terrain` 대신 이것.
|
||||
`as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로).
|
||||
응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial,
|
||||
initial_refreshed, pipes: {kept, crossing}, structures: {moved, far}, cross_dropped,
|
||||
@@ -63,6 +64,7 @@ class RouteDraftInput(BaseModel):
|
||||
curveLock: list[str | None] = Field(default_factory=list) # noqa: N815
|
||||
curveArc: list[float | None] = Field(default_factory=list) # noqa: N815
|
||||
stationSnap: bool = False # noqa: N815
|
||||
stationSnapStep: float | None = None # noqa: N815 — 390-5
|
||||
terrain: Literal["las", "sheet"] | None = None
|
||||
|
||||
|
||||
@@ -97,6 +99,7 @@ class RouteSaveRequest(RouteReplanRequest):
|
||||
update={
|
||||
"vertices": vertices,
|
||||
"station_snap": draft.stationSnap,
|
||||
"station_snap_step": draft.stationSnapStep,
|
||||
"terrain": draft.terrain,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ from fastapi.responses import JSONResponse
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step
|
||||
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
|
||||
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||
RouteInitialCriteria,
|
||||
@@ -252,10 +252,12 @@ def _write_planned_polyline(
|
||||
curve_flags: list[bool] | None = None,
|
||||
radii: list[float | None] | None = None,
|
||||
station_snap: bool | None = None,
|
||||
station_snap_step: float | None = None,
|
||||
) -> dict:
|
||||
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
|
||||
|
||||
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔).
|
||||
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) ·
|
||||
`station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.).
|
||||
|
||||
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
||||
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
||||
@@ -276,6 +278,8 @@ def _write_planned_polyline(
|
||||
}
|
||||
if station_snap is not None:
|
||||
table["station_snap"] = bool(station_snap)
|
||||
if station_snap and station_snap_step is not None:
|
||||
table["station_snap_step"] = float(station_snap_step)
|
||||
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
|
||||
return {
|
||||
"nodes": len(result.nodes),
|
||||
@@ -539,6 +543,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"violation_count": outline.violation_count,
|
||||
"edited": bool(working),
|
||||
"station_snap": await asyncio.to_thread(load_station_snap, project_root),
|
||||
"station_snap_step": await asyncio.to_thread(load_station_snap_step, project_root),
|
||||
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
|
||||
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
|
||||
}
|
||||
@@ -589,6 +594,7 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
|
||||
curve_flags=curve_flags,
|
||||
radii=radii,
|
||||
station_snap=request.station_snap,
|
||||
station_snap_step=request.station_snap_step,
|
||||
),
|
||||
working_path,
|
||||
nodes,
|
||||
|
||||
@@ -382,3 +382,5 @@ class RouteReplanRequest(BaseModel):
|
||||
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
|
||||
station_snap: bool = False
|
||||
"""[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음."""
|
||||
station_snap_step: float | None = None
|
||||
"""스냅 간격(m · 0.1 · 1 · 10 · None = 측점 No. · 390-5) — 곡선표 `"station_snap_step"`."""
|
||||
|
||||
@@ -62,11 +62,6 @@ import {
|
||||
viewPxPerMeter,
|
||||
} from "./B05_Profile_UI_RouteEdit_View";
|
||||
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
||||
import {
|
||||
curveSpans,
|
||||
snapCurveStations,
|
||||
stationsAlong,
|
||||
} from "./logic/B05_Profile_UI_RouteEdit_Frame";
|
||||
import {
|
||||
lockColumn,
|
||||
readRouteLocks,
|
||||
@@ -161,19 +156,15 @@ export async function mountRouteEditor(
|
||||
let apexLock: boolean[] = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
||||
let picked = -1;
|
||||
/** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 일반측점에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */
|
||||
/** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 고른 간격 배수에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */
|
||||
let stationSnap = false;
|
||||
let savedStationSnap = false;
|
||||
/** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로.
|
||||
* 그리기 · 누름 · 기울기가 이것을 봄 · 셈은 서버 짝(`_Frame`). */
|
||||
const stations = (): number[] | null => {
|
||||
if (!stationSnap) return null;
|
||||
const line = plannedLine.length ? plannedLine : planned;
|
||||
return snapCurveStations(
|
||||
stationsAlong(line, stationIntervalM, false),
|
||||
curveSpans(line, curveInfo),
|
||||
);
|
||||
};
|
||||
/** 간격 · 저장 비교 · 편집 창 측점(끔이면 null · 그리기 · 누름 · 기울기가 봄)은 `_SnapStep` 몫(390-2 · 5). */
|
||||
const snap = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => {
|
||||
if (toggle) setStationSnap(!stationSnap);
|
||||
if (stationSnap && history) applyEdit(snap.text()); // 전체 · 한 걸음
|
||||
draw();
|
||||
});
|
||||
const stations = () => snap.stations(plannedLine.length ? plannedLine : planned, curveInfo);
|
||||
const setStationSnap = (on: boolean) => paintStationSnap(stationSnapButton, (stationSnap = on));
|
||||
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
||||
let history: RouteEditHistory | null = null;
|
||||
@@ -261,11 +252,6 @@ export async function mountRouteEditor(
|
||||
const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() });
|
||||
/** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */
|
||||
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
|
||||
const snapStepM = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => {
|
||||
if (toggle) setStationSnap(!stationSnap);
|
||||
if (stationSnap && history) applyEdit(snapStepM.text()); // 전체 · 한 걸음
|
||||
draw();
|
||||
});
|
||||
/** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */
|
||||
let viewReady = false;
|
||||
const viewMemory = createViewMemory(projectId, () =>
|
||||
@@ -413,7 +399,7 @@ export async function mountRouteEditor(
|
||||
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
||||
minRadiusM,
|
||||
fresh: nodeInfo.length === 0,
|
||||
offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, snapStepM()) : 0,
|
||||
offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0,
|
||||
});
|
||||
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
|
||||
@@ -447,9 +433,8 @@ export async function mountRouteEditor(
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다.
|
||||
* 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */
|
||||
function applyEdit(message: string, record = true, fitting = true): void {
|
||||
const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: snapStepM() };
|
||||
const from = fit.fitStart(history?.current?.(), snapshotNow());
|
||||
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), limits, from);
|
||||
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), fitLimits(), from);
|
||||
markEdited();
|
||||
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
|
||||
syncCurveBar();
|
||||
@@ -459,6 +444,11 @@ export async function mountRouteEditor(
|
||||
historyControls.sync();
|
||||
}
|
||||
|
||||
/** 측점 스냅 맞춤 값 — 하한 · 스냅 간격(390-2). */
|
||||
function fitLimits(): fit.FitLimits {
|
||||
return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap() };
|
||||
}
|
||||
|
||||
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
||||
function snapshotNow(): RouteEditSnapshot {
|
||||
return {
|
||||
@@ -535,10 +525,16 @@ export async function mountRouteEditor(
|
||||
curveHint,
|
||||
contourHint,
|
||||
commit: () => {
|
||||
if (stationSnap) return applyEdit(snapStepM.text()); // 놓을 때(366-3)
|
||||
if (stationSnap) return applyEdit(snap.text()); // 놓을 때(366-3)
|
||||
history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
},
|
||||
snapStepM: snap,
|
||||
fitDrag: (index) => {
|
||||
const base = stationSnap ? history?.current?.()?.planned : undefined;
|
||||
if (base) planned = fit.fitDrag(snapshotNow(), base, index, fitLimits());
|
||||
if (base) markEdited();
|
||||
},
|
||||
});
|
||||
|
||||
bindNodeSplice({
|
||||
@@ -582,11 +578,9 @@ export async function mountRouteEditor(
|
||||
nodes: () => ({ planned, curveOn, curveRadius }),
|
||||
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
|
||||
changed: () =>
|
||||
(history?.isDirty() ?? false) ||
|
||||
pickedTerrain() !== designTerrain ||
|
||||
stationSnap !== savedStationSnap,
|
||||
(history?.isDirty() ?? false) || pickedTerrain() !== designTerrain || snap.changed(),
|
||||
terrain: pickedTerrain,
|
||||
stationSnap: () => stationSnap,
|
||||
snap: snap.draft,
|
||||
initial,
|
||||
onApplied,
|
||||
});
|
||||
@@ -613,8 +607,7 @@ export async function mountRouteEditor(
|
||||
minRadiusM = plan.min_radius_m ?? 0;
|
||||
limitRadiusM = plan.limit_radius_m ?? 0;
|
||||
limitArcM = plan.limit_curve_length_m ?? 0;
|
||||
savedStationSnap = plan.station_snap === true;
|
||||
setStationSnap(savedStationSnap);
|
||||
setStationSnap(snap.load(plan));
|
||||
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
|
||||
const flat = flattenServerPlan(nodes, plan.curves ?? []);
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc } = flat);
|
||||
@@ -624,7 +617,7 @@ export async function mountRouteEditor(
|
||||
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
history = draftLink.watch(history, options.draft, () => stationSnap);
|
||||
history = draftLink.watch(history, options.draft, snap.draft);
|
||||
draftLink.restore(options.draft, restoreSnapshot);
|
||||
bindRouteShift(host, projectId, snapshotNow, applyEdit, () => [plannedLine, curveInfo]); // 356-3
|
||||
historyControls.sync();
|
||||
@@ -674,7 +667,7 @@ export async function mountRouteEditor(
|
||||
setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las");
|
||||
// 계산 묶음에 알림 — 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
|
||||
const dirty = history?.isDirty() || pickedTerrain() !== designTerrain;
|
||||
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), stationSnap) : null);
|
||||
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null);
|
||||
const mine = ++terrainTicket;
|
||||
void loadBackground().then((background) => {
|
||||
if (closed || mine !== terrainTicket) return;
|
||||
|
||||
@@ -28,8 +28,10 @@ import {
|
||||
type RouteReplanResponse,
|
||||
type RouteSaveBody,
|
||||
} from "./B05_Profile_Api_Replan";
|
||||
import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep";
|
||||
|
||||
type Vertex = [number, number];
|
||||
type RouteSettings = SnapSetting & { terrain: string | null };
|
||||
type RouteNodes = { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
|
||||
|
||||
export interface RouteApplyParams {
|
||||
@@ -43,8 +45,8 @@ export interface RouteApplyParams {
|
||||
changed: () => boolean;
|
||||
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
|
||||
terrain: () => string | null;
|
||||
/** [측점 스냅] 켬끔(요구 224-2) — 곡선표에 적혀 측점 셈이 읽음. */
|
||||
stationSnap: () => boolean;
|
||||
/** [측점 스냅] 켬끔(요구 224-2) · 간격(390-5) — 곡선표에 적혀 측점 셈이 읽음. */
|
||||
snap: () => SnapSetting;
|
||||
/** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
|
||||
initial: {
|
||||
current: () => unknown;
|
||||
@@ -56,7 +58,7 @@ export interface RouteApplyParams {
|
||||
|
||||
interface RouteSource {
|
||||
pending: () => Promise<RouteSaveBody | null>;
|
||||
settings: () => { stationSnap: boolean; terrain: string | null };
|
||||
settings: () => RouteSettings;
|
||||
saved: () => void;
|
||||
rebuild: () => void | Promise<void>;
|
||||
}
|
||||
@@ -64,10 +66,7 @@ interface RouteSource {
|
||||
const sources = new Map<string, RouteSource>();
|
||||
|
||||
/** 노드 · 곡선 편집값 → 계약 `RouteDraft`(붙든 값은 서버가 안 씀 · 칸 길이만 맞춤). */
|
||||
export function routeDraftOf(
|
||||
nodes: RouteNodes,
|
||||
settings: { stationSnap: boolean; terrain: string | null },
|
||||
): RouteDraft {
|
||||
export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteDraft {
|
||||
return {
|
||||
rev: 0,
|
||||
nodes: nodes.planned.map(([x, y]): Vertex => [x, y]),
|
||||
@@ -76,13 +75,14 @@ export function routeDraftOf(
|
||||
curveLock: nodes.planned.map(() => null),
|
||||
curveArc: nodes.planned.map(() => null),
|
||||
stationSnap: settings.stationSnap,
|
||||
stationSnapStep: settings.stationSnapStep ?? null,
|
||||
terrain: settings.terrain,
|
||||
};
|
||||
}
|
||||
|
||||
/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. */
|
||||
export function bindRouteApply(params: RouteApplyParams): void {
|
||||
const settings = () => ({ stationSnap: params.stationSnap(), terrain: params.terrain() });
|
||||
const settings = () => ({ ...params.snap(), terrain: params.terrain() });
|
||||
sources.set(params.projectId, {
|
||||
pending: async () => {
|
||||
const nodes = params.nodes();
|
||||
@@ -175,8 +175,8 @@ export async function saveRecommendedRoute(
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await saveRouteDraft(projectId, {
|
||||
// 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬으로 저장
|
||||
draft: routeDraftOf(nodes, { ...settings, stationSnap: true }),
|
||||
// 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장
|
||||
draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }),
|
||||
initial,
|
||||
balanced_profile: balancedProfile,
|
||||
as_initial: true,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type RouteEditHistory,
|
||||
type RouteEditSnapshot,
|
||||
} from "./B05_Profile_UI_RouteEdit_History";
|
||||
import type { SnapSetting } from "./B05_Profile_UI_RouteEdit_SnapStep";
|
||||
|
||||
/** 편집기가 넘기는 노선 — 판 번호 · 지형은 다리가 붙임. */
|
||||
export type RouteDraftEdit = Omit<RouteDraft, "rev" | "terrain">;
|
||||
@@ -31,15 +32,15 @@ export interface RouteDraftBridge {
|
||||
saved(): RouteDraft | null;
|
||||
}
|
||||
|
||||
/** 사진 → 노선 초안(고정 · 고른 점은 노선이 아님). */
|
||||
export function draftOfSnapshot(snapshot: RouteEditSnapshot, stationSnap: boolean): RouteDraftEdit {
|
||||
/** 사진 → 노선 초안(고정 · 고른 점은 노선이 아님) · 스냅 켬끔 · 간격(390-5)은 `_SnapStep` 몫. */
|
||||
export function draftOfSnapshot(snapshot: RouteEditSnapshot, snap: SnapSetting): RouteDraftEdit {
|
||||
return {
|
||||
nodes: snapshot.planned.map(([x, y]): [number, number] => [x, y]),
|
||||
curveOn: [...snapshot.curveOn],
|
||||
curveRadius: [...snapshot.curveRadius],
|
||||
curveLock: [...snapshot.curveLock],
|
||||
curveArc: [...snapshot.curveArc],
|
||||
stationSnap,
|
||||
...snap,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,12 +61,11 @@ export function snapshotOfDraft(draft: RouteDraft): RouteEditSnapshot {
|
||||
export function watch(
|
||||
history: RouteEditHistory,
|
||||
bridge: RouteDraftBridge | undefined,
|
||||
stationSnap: () => boolean,
|
||||
snap: () => SnapSetting,
|
||||
): RouteEditHistory {
|
||||
if (!bridge) return history;
|
||||
const tell = (snapshot: RouteEditSnapshot | null): RouteEditSnapshot | null => {
|
||||
if (snapshot)
|
||||
bridge.edited(history.isDirty() ? draftOfSnapshot(snapshot, stationSnap()) : null);
|
||||
if (snapshot) bridge.edited(history.isDirty() ? draftOfSnapshot(snapshot, snap()) : null);
|
||||
return snapshot;
|
||||
};
|
||||
/** 사진첩이 되쓴 노선 초안 → 편집기 사진(같은 선의 옛 걸음이 있으면 고정 · 꼬리표째). */
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Keys.ts
|
||||
* 계획노선 편집 평면의 **교각점 방향키**(383-2 · 요구 241-16) — 고른 IP 를 화면 축으로 0.1 m
|
||||
* (Shift 1 m)씩. 누르는 동안 옮기기만 · 뗄 때 되돌리기 한 걸음(측점 스냅 켬이면 그때 MC 맞춤).
|
||||
* (Shift 1 m)씩 · 측점 스냅 켬이면 스냅 간격씩(390-5 · 걸음마다 MC 를 간격 배수에 맞춘 미리보기).
|
||||
* 뗄 때 되돌리기 한 걸음(측점 스냅 켬이면 그때 정본 맞춤).
|
||||
*
|
||||
* 받는 때 = 마지막 누름이 평면 편집 창(`.b05-routeedit`) 안 · 입력칸 아님. 그때 방향키는 평면이
|
||||
* 삼킴(IP 를 안 골랐어도) — 종단 계획고 ▲▼ · 횡단 조정창으로 새지 않음. 하한 검사 · 놓을 때 할
|
||||
@@ -28,6 +29,8 @@ interface Hold {
|
||||
index: number;
|
||||
before: Vertex[];
|
||||
start: Vertex;
|
||||
/** 키로 옮긴 자리(맞추기 전) — 다음 걸음은 여기서(맞춤이 쌓이지 않음). */
|
||||
at: Vertex;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
@@ -43,9 +46,10 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
|
||||
const state = params.state();
|
||||
const node = state.planned[now.index];
|
||||
if (!node) return;
|
||||
const [dx, dy] = arrowStep(now.key, now.shift, metricAt);
|
||||
const snapStepM = params.stations() !== null ? params.snapStepM() : undefined;
|
||||
const [dx, dy] = arrowStep(now.key, now.shift, metricAt, snapStepM);
|
||||
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
|
||||
state.planned[now.index] = [node[0] + dx, node[1] + dy];
|
||||
state.planned[now.index] = [now.at[0] + dx, now.at[1] + dy];
|
||||
params.markEdited();
|
||||
const after = params.state();
|
||||
if (
|
||||
@@ -60,6 +64,8 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
|
||||
return;
|
||||
}
|
||||
now.moved = true;
|
||||
now.at = state.planned[now.index];
|
||||
params.fitDrag(now.index);
|
||||
status.textContent = `${params.routeHead()} — 방향키로 옮기는 중(뗄 때 한 걸음). ${params.curveHint()}`;
|
||||
params.draw();
|
||||
}
|
||||
@@ -106,6 +112,7 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
|
||||
index,
|
||||
before: state.planned.map(([x, y]): Vertex => [x, y]),
|
||||
start: [node[0], node[1]],
|
||||
at: [node[0], node[1]],
|
||||
moved: false,
|
||||
};
|
||||
hold = now;
|
||||
|
||||
@@ -99,6 +99,10 @@ export interface RouteEditPointerParams {
|
||||
contourHint: () => string;
|
||||
/** 끌기 한 번을 되돌리기에 쌓는다. */
|
||||
commit: () => void;
|
||||
/** 스냅 간격(m · 390-5) — 스냅 켬이면 방향키 한 걸음. */
|
||||
snapStepM: () => number;
|
||||
/** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에. */
|
||||
fitDrag: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface RouteEditPointer {
|
||||
@@ -322,6 +326,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
params.draw();
|
||||
return;
|
||||
}
|
||||
params.fitDrag(dragNode); // 스냅 켬 = 끄는 동안 MC 를 간격 배수에(390-5 · 놓을 때 정본)
|
||||
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
|
||||
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
|
||||
status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`;
|
||||
|
||||
@@ -3,16 +3,30 @@
|
||||
* 노선 편집 [측점 스냅] 단추 · 스냅 간격 고르기(390-2 · 요구 242-11).
|
||||
*
|
||||
* 간격 = 0.1 m · 1 m · 10 m · 측점 No.(편집 창 측점 간격 · 기본) — 곡선 MC 를 그 간격 배수에 맞춤.
|
||||
* 고른 값은 프로젝트별 화면 기억(pref `routeedit-snap-step`) · [저장] 대상 아님(서버 · 추천은 측점 No. 그대로).
|
||||
* 고른 값은 프로젝트별 화면 기억(pref `routeedit-snap-step`) · 스냅 켬이면 저장 노선에도 같이
|
||||
* (`station_snap_step` · 390-5 · 서버 측점 셈이 MC 측점 값 자릿수로 씀 · 옛 노선 = 측점 No.).
|
||||
* 저장 노선이 스냅 켬이면 열 때 그 간격으로 고르기를 맞춤. 추천 · 서버 초기 노선은 측점 No. 그대로.
|
||||
* 스냅 끔이면 고르기는 흐림(CSS) · 고를 수는 있음.
|
||||
* 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 그 밖 = 「1 m 배수에」 꼴.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
|
||||
import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
|
||||
import type { EditedCurve, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
curveSpans,
|
||||
snapCurveStations,
|
||||
stationsAlong,
|
||||
} from "./logic/B05_Profile_UI_RouteEdit_Frame";
|
||||
|
||||
const STEPS = ["0.1", "1", "10", "station"];
|
||||
|
||||
/** 저장 · 초안에 실리는 스냅 두 칸. */
|
||||
export type SnapSetting = Pick<RouteDraft, "stationSnap" | "stationSnapStep">;
|
||||
|
||||
type SavedSnap = { station_snap?: boolean; station_snap_step?: number | null };
|
||||
|
||||
/** [측점 스냅] 단추 켬끔 모양(`is-active` · `aria-pressed`). */
|
||||
export function paintStationSnap(button: HTMLButtonElement, on: boolean): void {
|
||||
button.classList.toggle("is-active", on);
|
||||
@@ -25,12 +39,22 @@ export function snapFitText(step: string): string {
|
||||
return `곡선 가운데(MC)를 ${where}에 맞췄습니다.`;
|
||||
}
|
||||
|
||||
export type SnapStep = { (): number; text: () => string };
|
||||
export type SnapStep = {
|
||||
/** 지금 스냅 간격(m) — 측점 No. 면 측점 간격. */
|
||||
(): number;
|
||||
/** 스냅 뒤 상태줄 글. */
|
||||
text: () => string;
|
||||
/** 저장 · 초안 두 칸 — 끔이면 간격 null. */
|
||||
draft: () => SnapSetting;
|
||||
/** 저장 노선을 읽음 — 켬이면 고르기를 그 간격으로 · 돌려줌 = 켬끔. */
|
||||
load: (plan: SavedSnap) => boolean;
|
||||
/** 읽은 저장 노선과 켬끔 · 간격이 다른가([확인] 보낼지). */
|
||||
changed: () => boolean;
|
||||
/** 편집 창 측점 — 끔이면 null · 켬이면 호 안 하나를 MC 로(자릿수 = 간격 · 셈은 서버 짝 `_Frame`). */
|
||||
stations: (line: ReadonlyArray<Vertex>, curves: ReadonlyArray<EditedCurve>) => number[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈.
|
||||
* 돌려주는 함수 = 지금 스냅 간격(m) · `.text()` = 스냅 뒤 상태줄 글.
|
||||
*/
|
||||
/** 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈. */
|
||||
export function bindStationSnap(
|
||||
chrome: Pick<RouteEditChrome, "overlay" | "stationSnapButton">,
|
||||
projectId: string,
|
||||
@@ -45,6 +69,34 @@ export function bindStationSnap(
|
||||
onChange(false);
|
||||
});
|
||||
chrome.stationSnapButton.addEventListener("click", () => onChange(true));
|
||||
const stepM = () => (select.value === "station" ? stationIntervalM : Number(select.value));
|
||||
return Object.assign(stepM, { text: () => snapFitText(select.value) });
|
||||
const on = () => chrome.stationSnapButton.getAttribute("aria-pressed") === "true";
|
||||
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 stepM = () => step() ?? stationIntervalM;
|
||||
return Object.assign(stepM, {
|
||||
text: () => snapFitText(select.value),
|
||||
draft,
|
||||
load: (plan: SavedSnap) => {
|
||||
const snapOn = plan.station_snap === true;
|
||||
const kept = String(plan.station_snap_step ?? "station");
|
||||
if (snapOn) select.value = STEPS.includes(kept) ? kept : "station";
|
||||
loaded = { stationSnap: snapOn, stationSnapStep: snapOn ? step() : null };
|
||||
return snapOn;
|
||||
},
|
||||
changed: () => {
|
||||
const now = draft();
|
||||
return (
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* 교각점 방향키(383-2 · 요구 241-16) — 화면 축 걸음 · 뗄 때 맞춘 결과 한 줄.
|
||||
*
|
||||
* · 한 번 0.1 m · Shift ×10 · 위 = 화면 위(지도를 돌려도 화면 기준).
|
||||
* · 측점 스냅 켬이면 뗄 때 MC 를 일반측점에 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
|
||||
* · 측점 스냅 켬이면 한 번 = 스냅 간격(0.1 · 1 · 10 m · 측점 No. · Shift 안 씀 · 390-5) · 걸음마다
|
||||
* MC 를 그 간격 배수에 맞춘 자리로 보임 · 뗄 때 정본 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림.
|
||||
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Keys`.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -25,12 +26,14 @@ export function isArrowKey(key: string): key is ArrowKey {
|
||||
|
||||
/**
|
||||
* 방향키 한 번의 도면 좌표 걸음 — `metricAt(화면 x, y)` 로 화면 위 · 오른쪽 1 px 를 도면 좌표로 바꿔
|
||||
* 그 방향 단위 벡터 × 걸음. 돌린 지도 · 확대와 무관하게 화면 축.
|
||||
* 그 방향 단위 벡터 × 걸음. 돌린 지도 · 확대와 무관하게 화면 축. `snapStepM` = 스냅 켬일 때 간격
|
||||
* (있으면 걸음 = 그 값 · Shift 무시).
|
||||
*/
|
||||
export function arrowStep(
|
||||
key: ArrowKey,
|
||||
shift: boolean,
|
||||
metricAt: (x: number, y: number) => Vertex,
|
||||
snapStepM?: number,
|
||||
): Vertex {
|
||||
const [x0, y0] = metricAt(0, 0);
|
||||
const [dx, dy] =
|
||||
@@ -44,7 +47,7 @@ export function arrowStep(
|
||||
const [x1, y1] = metricAt(dx, dy);
|
||||
const length = Math.hypot(x1 - x0, y1 - y0);
|
||||
if (!(length > 0)) return [0, 0];
|
||||
const step = KEY_STEP_M * (shift ? KEY_SHIFT_TIMES : 1);
|
||||
const step = snapStepM ?? KEY_STEP_M * (shift ? KEY_SHIFT_TIMES : 1);
|
||||
return [((x1 - x0) / length) * step, ((y1 - y0) / length) * step];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
|
||||
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
|
||||
* · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`).
|
||||
* · 끄는 동안 · 방향키 걸음마다 미리보기(`fitDrag` · 390-5) = 누르기 전 노선 + 옮긴 IP 하나로 매번 새로
|
||||
* 맞춤(앞 프레임 맞춤이 쌓이지 않음) · 놓을 때 `fitCurveStations` 정본 맞춤이 같은 값.
|
||||
* 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로.
|
||||
* 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`.
|
||||
* ========================================================================== */
|
||||
@@ -173,3 +175,17 @@ export function fitCurveStations(
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
|
||||
/** 끄는 중 미리보기(390-5) — `base`(누르기 전 IP) 에 IP `k` 만 지금 자리(`cols.planned[k]`)로 두고
|
||||
* `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작). */
|
||||
export function fitDrag(
|
||||
cols: Columns & Pick<NodeColumns, "planned">,
|
||||
base: ReadonlyArray<Vertex>,
|
||||
k: number,
|
||||
limits: FitLimits,
|
||||
): Vertex[] {
|
||||
if (base.length !== cols.planned.length)
|
||||
return fitCurveStations(cols, limits, Math.max(1, k - 1));
|
||||
const planned = base.map((vertex, i): Vertex => (i === k ? cols.planned[k] : vertex));
|
||||
return fitCurveStations({ ...cols, planned }, limits, Math.max(1, k - 1));
|
||||
}
|
||||
|
||||
@@ -226,6 +226,8 @@ export interface StationsOptions {
|
||||
intervalM?: number;
|
||||
includeEndpoint?: boolean;
|
||||
stationSnap?: boolean;
|
||||
/** 스냅 간격(m · null = 측점 No.) — MC 측점 값 자릿수(390-5 · `station_snap_step` 짝). */
|
||||
stationSnapStep?: number | null;
|
||||
/** 구조물 정의(종류 · 군 · 자리꼴) — 심는 측점 판정. */
|
||||
types: ReadonlyArray<PlanType>;
|
||||
/** 종류 → 옵션 기본값(카드 이름) — `planStations` 그대로. */
|
||||
@@ -286,6 +288,7 @@ export function buildStations(
|
||||
? snapCurveStations(
|
||||
original,
|
||||
spans.map(([bc, ec]) => [bc, ec] as const),
|
||||
options.stationSnapStep,
|
||||
)
|
||||
: original;
|
||||
const gridIndex = gridIndices(original, grid, intervalM);
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
* (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고
|
||||
* 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림.
|
||||
*
|
||||
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations`
|
||||
* ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` ·
|
||||
* `snap_curve_stations`. 거울 시험 `resources/tester/test_224_2_station_snap.py`.
|
||||
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations` ·
|
||||
* `mcScale` ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` ·
|
||||
* `snap_curve_stations` · `_mc_scale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` ·
|
||||
* 간격별 MC 값(390-5) `test_390_5_snap_step_ops.py`.
|
||||
* ========================================================================== */
|
||||
|
||||
type Vertex = [number, number];
|
||||
@@ -155,12 +156,18 @@ export function curveSpans(
|
||||
return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
}
|
||||
|
||||
/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC+0.5) 로 옮김 ·
|
||||
* 측점 없는 호는 그대로 · 같은 m 는 하나로(오름차순). `snap_curve_stations` 짝. */
|
||||
/** MC 측점 값 자릿수 배수 — 스냅 간격 1 m 미만(0.1) = 소수 한 자리(10) · 그 밖(1 · 10 m · 측점 No. = null) = 정수(1)
|
||||
* (390-5). `_mc_scale` 짝. */
|
||||
export const mcScale = (stepM?: number | null): number => (stepM != null && stepM < 1 ? 10 : 1);
|
||||
|
||||
/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC·s+0.5)/s 로 옮김(s = `mcScale`) ·
|
||||
* 측점 없는 호는 그대로 · 같은 값은 하나로(오름차순). `snap_curve_stations` 짝. */
|
||||
export function snapCurveStations(
|
||||
stations: ReadonlyArray<number>,
|
||||
spans: ReadonlyArray<readonly [number, number]>,
|
||||
stepM?: number | null,
|
||||
): number[] {
|
||||
const scale = mcScale(stepM);
|
||||
const values = [...stations];
|
||||
for (const [start, end] of spans) {
|
||||
const middle = (start + end) / 2;
|
||||
@@ -170,7 +177,7 @@ export function snapCurveStations(
|
||||
if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle))
|
||||
nearest = index;
|
||||
});
|
||||
if (nearest >= 0) values[nearest] = Math.floor(middle + 0.5);
|
||||
if (nearest >= 0) values[nearest] = Math.floor(middle * scale + 0.5) / scale;
|
||||
}
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import { useStationKeys } from "./B06_Section_Station_Keys";
|
||||
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
|
||||
import { hasStaleDesigns, staleKey } from "./B06_Section_UI_Section_Common";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import { withPipeOptions } from "./B06_Section_Section_Adjusts";
|
||||
|
||||
@@ -156,16 +156,26 @@ export function holdScreenAlignment(line: ScreenLine): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */
|
||||
export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean {
|
||||
/** 화면 선을 얹은 견줌 꼴 — 화면 선이 없으면 상세 그대로. */
|
||||
function againstScreen(detail: SectionDetailResponse, routeId: number) {
|
||||
const screen = screenLine?.(routeId);
|
||||
if (!screen) return hasStaleDesigns(detail);
|
||||
return hasStaleDesigns({
|
||||
if (!screen) return detail;
|
||||
return {
|
||||
longitudinal: {
|
||||
design_profiles: [toDesignProfile(screen, detail.longitudinal.design_profiles?.[0])],
|
||||
},
|
||||
cross_sections: detail.cross_sections,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */
|
||||
export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean {
|
||||
return hasStaleDesigns(againstScreen(detail, routeId));
|
||||
}
|
||||
|
||||
/** 화면 선 기준 낡은 측점 지문(모두 맞으면 "") — 상세 가게가 같은 낡음에 cross 를 되풀이하지 않게(387-8). */
|
||||
export function staleKeyAgainstScreen(detail: SectionDetailResponse, routeId: number): string {
|
||||
return staleKey(againstScreen(detail, routeId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,7 @@ import { withDraftAdjusts, withPipeOptions } from "./B06_Section_Section_Adjusts
|
||||
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { forgetCanon, keepCanon, sessionDetail } from "./B06_Section_Section_Canon";
|
||||
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
|
||||
import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
|
||||
import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
|
||||
import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options";
|
||||
import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types";
|
||||
import {
|
||||
@@ -74,7 +74,11 @@ async function withDraftWalls(
|
||||
await withDraftShapes(detail, projectId);
|
||||
withDraftAdjusts(detail, projectId, routeId); // 저장 안 한 조정창 4축 · 구간 · 형식(387-4 · B05 만 열어도)
|
||||
await withPipeOptions(detail, projectId); // 관 옵션 벽 높이 · 형태 · 흙막이 단(387-4b)
|
||||
return withStructureAreas(detail);
|
||||
withStructureAreas(detail);
|
||||
// 초안 자리 미리 뜬 횡단 = 서버가 저장 계획선으로 셈 · 묶음 cross 뒤에 얹힐 수 있음 — 다 돈 뒤 지금 계획선과
|
||||
// 갈리면 cross 한 번 더(387-8 · 낡음 판정 = 가게 몫 · 종단 판이 접혀 그리기가 안 돌아도)
|
||||
recrossSaved(projectId, keyOf(projectId, routeId), detail, routeId);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,20 +179,36 @@ export async function swapSectionStations(
|
||||
return { detail: swap.detail, moves: swap.moves };
|
||||
}
|
||||
|
||||
/** 상세마다 마지막으로 cross 를 부탁한 낡음 지문 — 다시 셈이 못 고친 낡음에 되풀이하지 않음(387-8). */
|
||||
const askedStale = new WeakMap<SectionDetailResponse, string>();
|
||||
|
||||
/** 저장 노선 측점을 갈아끼움 — 그 차례 cross 는 옛 상세를 봐서 새 상세 설계 · 면적이 안 셈 → 묶음이 다 돈 뒤(새 측점
|
||||
* 종단 · 화면 선이 선 뒤 · 그 전에 세면 계획고 0.5 mm 갈림) cross 한 번 더(387-4b · B05 만 연 면적 = B06). 노선 초안
|
||||
* 동안은 묶음 새 노선 상세가 맡음. 묶음은 늦게 불러옴(가게 ↔ 묶음 순환 없음). */
|
||||
function recrossSaved(projectId: string, key: string, detail: SectionDetailResponse): void {
|
||||
* 동안은 묶음 새 노선 상세가 맡음. 묶음은 늦게 불러옴(가게 ↔ 묶음 순환 없음).
|
||||
* `staleOf`(노선 번호)를 주면 화면 선 대비 낡을 때만 · 같은 낡음은 한 번만(387-8 · 묶음이 아직 안 섰으면 그 첫 차례 몫). */
|
||||
function recrossSaved(
|
||||
projectId: string,
|
||||
key: string,
|
||||
detail: SectionDetailResponse,
|
||||
staleOf?: number,
|
||||
): void {
|
||||
void Promise.all([
|
||||
import("../A00_Common/design/design_pipeline_cross"),
|
||||
import("../A00_Common/design/design_pipeline_page"),
|
||||
])
|
||||
.then(async ([cross, page]) => {
|
||||
await page.reconcilePagePipeline(projectId);
|
||||
// 낡음 확인 = 지금 차례만 기다림(맞춤은 묶음 몫 · 읽을 때마다 맞추면 되돌리기 차례가 갈림)
|
||||
if (staleOf === undefined) await page.reconcilePagePipeline(projectId);
|
||||
else await page.pagePipeline(projectId)?.settled();
|
||||
// 그 사이 다른 상세 · 다시 셈 창구 없음(묶음만 · 시험) · 노선 초안 = 안 부탁
|
||||
if (cache.get(key) !== detail) return;
|
||||
if (!page.pipelineOwnsCross(projectId) || page.pagePipeline(projectId)?.input("route"))
|
||||
return;
|
||||
const pipe = page.pagePipeline(projectId);
|
||||
if (!page.pipelineOwnsCross(projectId) || pipe?.input("route")) return;
|
||||
if (staleOf !== undefined) {
|
||||
const stale = pipe?.generation ? staleKeyAgainstScreen(detail, staleOf) : "";
|
||||
if (!stale || askedStale.get(detail) === stale) return;
|
||||
askedStale.set(detail, stale);
|
||||
}
|
||||
cross.forceCross();
|
||||
page.pipelineCross(projectId);
|
||||
})
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* ========================================================================== */
|
||||
|
||||
/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0` · `5+20`).
|
||||
* 화면 표기만 거리를 정수로(119.8 → 5+20 · 2026-09-29 사용자 확정) — 입력 · 저장값은 소수 그대로. */
|
||||
export function stationLabel(chainage: number, interval: number): string {
|
||||
* 화면 표기만 거리를 정수로(119.8 → 5+20 · 2026-09-29 사용자 확정) — 입력 · 저장값은 소수 그대로.
|
||||
* `decimals = 1` = 소수 한 자리까지(B05 평면 스냅 간격 0.1 의 MC · 390-5 · 정수면 그대로 `5+0`). */
|
||||
export function stationLabel(chainage: number, interval: number, decimals: 0 | 1 = 0): string {
|
||||
const safeInterval = interval > 0 ? interval : 1;
|
||||
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
|
||||
let remainder = chainage - stationNumber * safeInterval;
|
||||
@@ -16,10 +17,15 @@ export function stationLabel(chainage: number, interval: number): string {
|
||||
stationNumber += 1;
|
||||
remainder = 0;
|
||||
}
|
||||
return stationPartsLabel(stationNumber, remainder);
|
||||
return stationPartsLabel(stationNumber, remainder, decimals);
|
||||
}
|
||||
|
||||
/** 이미 나눈 측점번호 · 잔여거리의 표기 — 거리는 정수로 보이기만. */
|
||||
export function stationPartsLabel(stationNumber: number, remainder: number): string {
|
||||
return `${stationNumber}+${Math.round(remainder)}`;
|
||||
/** 이미 나눈 측점번호 · 잔여거리의 표기 — 거리는 정수로 보이기만(`decimals = 1` = 소수 한 자리까지). */
|
||||
export function stationPartsLabel(
|
||||
stationNumber: number,
|
||||
remainder: number,
|
||||
decimals: 0 | 1 = 0,
|
||||
): string {
|
||||
const scale = decimals ? 10 : 1;
|
||||
return `${stationNumber}+${Math.round(remainder * scale) / scale}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/* 387-8 헬퍼 — 종단 판이 접힘(그리기 · 그리기 낡음 판정 없음) + 관 초안 둔 채 다시 열기: 진짜 상세 가게
|
||||
* (`B06_Section_Section_Store`) · 진짜 낡음 판정(`B06_Section_Cross_Refresh` staleKeyAgainstScreen)이 묶음 cross
|
||||
* 뒤에 얹힌 미리 뜬 측점(서버가 옛 계획고로 셈)을 보고 묶음에 cross 한 번 더 부탁하는지. 묶음 · 초안 얹기 · 서버는 가짜 —
|
||||
* 가짜 cross = 상세 측점마다 계획고를 지금 계획선으로(면적 = 계획고 함수). 짝 = `test_387_8_folded_profile.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const P = "P387";
|
||||
const ROUTE = 7;
|
||||
const PLAN = 100; // 지금 계획선(평평)
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||
setItem: (k, v) => map.set(k, String(v)),
|
||||
removeItem: (k) => map.delete(k),
|
||||
key: (i) => [...map.keys()][i] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() };
|
||||
|
||||
const anyModule = (extra = {}) =>
|
||||
new Proxy(extra, { get: (t, key) => (key in t ? t[key] : () => undefined) });
|
||||
|
||||
/** 가짜 계산 묶음 — 부탁 수 · 강제 수 · cross = 상세 계획고 맞춤. */
|
||||
const pipe = { generation: 1, route: null, crosses: 0, forced: 0, fix: true };
|
||||
const areaOf = (elevation) => Math.round((elevation - 90) * 1000) / 1000;
|
||||
function cross() {
|
||||
pipe.crosses += 1;
|
||||
if (!pipe.fix) return;
|
||||
for (const section of storeDetail().cross_sections) {
|
||||
section.design = {
|
||||
...section.design,
|
||||
design_elevation_m: PLAN,
|
||||
cut_area_m2: areaOf(PLAN),
|
||||
};
|
||||
}
|
||||
}
|
||||
let previewAt = null; // 관 초안 자리(미리 뜬 측점)
|
||||
const STUBS = {
|
||||
"design_pipeline_page.ts": {
|
||||
reconcilePagePipeline: async () => 0,
|
||||
pipelineOwnsCross: () => true,
|
||||
pagePipeline: () => ({
|
||||
generation: pipe.generation,
|
||||
input: () => pipe.route,
|
||||
settled: async () => undefined,
|
||||
}),
|
||||
pipelineCross: () => cross(),
|
||||
},
|
||||
"design_pipeline_cross.ts": { forceCross: () => void (pipe.forced += 1) },
|
||||
// 초안 측점 — 관 초안 자리에 서버가 미리 뜬 횡단(저장 계획선 = 옛 계획고 102)을 얹음
|
||||
"B06_Section_Section_Drafts.ts": {
|
||||
forgetDraftStations: () => undefined,
|
||||
withDraftShapes: async () => undefined,
|
||||
withDraftStations: async (detail) => {
|
||||
if (previewAt === null || detail.cross_sections.some((s) => s.preview)) return;
|
||||
detail.cross_sections.push({
|
||||
chainage_m: previewAt,
|
||||
kind: "irregular",
|
||||
preview: true,
|
||||
samples: [],
|
||||
design: { design_elevation_m: 102, cut_area_m2: areaOf(102) },
|
||||
});
|
||||
detail.cross_sections.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
},
|
||||
},
|
||||
"B05_Profile_Api_PlusStations.ts": {
|
||||
loadSavedPlusStations: async () => undefined,
|
||||
plusStationsOf: () => [],
|
||||
},
|
||||
"B05_Profile_Api_Pipes_Draft.ts": { readPendingPipes: () => null },
|
||||
"design_page_data.ts": { pagePipes: async () => ({ pipe_points: [] }) },
|
||||
"B06_Section_Api_Fetch.ts": anyModule(),
|
||||
"B06_Section_Api_Culvert_Options.ts": anyModule({ STRUCTURE_ADJUST_KEYS: [] }),
|
||||
"B06_Section_Swap_Bus.ts": anyModule(),
|
||||
"B06_Section_Station_Keys.ts": anyModule({ KEYED_STATES: [] }),
|
||||
"B06_Section_UI_Page_Persist.ts": anyModule(),
|
||||
"B06_Section_UI_Standard_Panel.ts": anyModule(),
|
||||
"B06_Section_Cross_Design_Session.ts": anyModule(),
|
||||
"common_util_cross_design.ts": anyModule(),
|
||||
"B06_Section_UI_Cross_Culvert_Wire.ts": anyModule(),
|
||||
"B06_Section_UI_Cross_Ford_Geom.ts": anyModule(),
|
||||
"B06_Section_UI_Cross_Box_Geom.ts": anyModule(),
|
||||
"B06_Section_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }),
|
||||
"B05_Profile_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }),
|
||||
"config_frontend.ts": anyModule({ PIPE_TYPES: [] }),
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
const resolve = (base) =>
|
||||
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
||||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||||
) ?? `${base}.ts`;
|
||||
function loadTs(full) {
|
||||
const stub = STUBS[path.basename(full)];
|
||||
if (stub) return stub;
|
||||
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,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const near = (request) => {
|
||||
if (CATALOG.isCatalogRequest(request)) return CATALOG;
|
||||
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
||||
for (const [prefix, dir] of Object.entries(alias))
|
||||
if (request.startsWith(prefix))
|
||||
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, near);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
|
||||
const store = loadTs(B06("B06_Section_Section_Store"));
|
||||
const { writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts"));
|
||||
const storeDetail = () => store.peekSectionDetail(P);
|
||||
|
||||
/** 저장 정본 — 격자 0 · 20 · 40 · 60(지금 계획선으로 셈한 설계) · 계획선 평평 100. */
|
||||
const canon = () => ({
|
||||
longitudinal: {
|
||||
design_profiles: [
|
||||
{
|
||||
samples: [
|
||||
{ chainage_m: 0, elevation_m: PLAN },
|
||||
{ chainage_m: 60, elevation_m: PLAN },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
cross_sections: [0, 20, 40, 60].map((c) => ({
|
||||
chainage_m: c,
|
||||
kind: c === 0 ? "bp" : "regular",
|
||||
samples: [],
|
||||
design: { design_elevation_m: PLAN, cut_area_m2: areaOf(PLAN) },
|
||||
})),
|
||||
});
|
||||
const tick = () => new Promise((r) => setTimeout(r, 20));
|
||||
const sum = () =>
|
||||
Math.round(storeDetail().cross_sections.reduce((a, s) => a + s.design.cut_area_m2, 0) * 1000) /
|
||||
1000;
|
||||
const mixed = () =>
|
||||
storeDetail()
|
||||
.cross_sections.filter((s) => s.design.design_elevation_m !== PLAN)
|
||||
.map((s) => s.chainage_m);
|
||||
const counts = () => ({ crosses: pipe.crosses, forced: pipe.forced });
|
||||
|
||||
async function open(fresh = true) {
|
||||
if (fresh) {
|
||||
store.invalidateSectionDetail(P);
|
||||
writeState("section-detail", canon(), P, ROUTE);
|
||||
}
|
||||
await store.loadSectionDetail(P, ROUTE);
|
||||
await tick();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const out = {};
|
||||
// 펼친 때 기준 — 미리 뜬 측점이 첫 cross 전에 얹혀 같이 셈
|
||||
previewAt = 30;
|
||||
await open();
|
||||
cross();
|
||||
out.expanded = { sum: sum(), mixed: mixed() };
|
||||
// 접힌 종단 판 + 관 초안 다시 열기 — 미리 뜬 측점이 cross 뒤에 얹힘(그리기 판정 없음) → 가게가 한 번 더 부탁
|
||||
Object.assign(pipe, { crosses: 0, forced: 0 });
|
||||
await open();
|
||||
out.folded = { sum: sum(), mixed: mixed(), ...counts() };
|
||||
// 같은 상세 다시 읽음(보기 전환) — 낡음 없음 = 부탁 없음
|
||||
await open(false);
|
||||
out.again = counts();
|
||||
// 다시 셈이 못 고치는 낡음 — 한 번만 부탁(되풀이 없음)
|
||||
Object.assign(pipe, { crosses: 0, forced: 0, fix: false });
|
||||
await open();
|
||||
await open(false);
|
||||
await open(false);
|
||||
out.unfixed = counts();
|
||||
pipe.fix = true;
|
||||
// 묶음이 아직 안 섬(첫 차례 몫) · 노선 초안(묶음 새 노선 상세 몫) = 안 부탁
|
||||
Object.assign(pipe, { crosses: 0, forced: 0, generation: 0 });
|
||||
await open();
|
||||
out.notStarted = counts();
|
||||
Object.assign(pipe, { crosses: 0, forced: 0, generation: 1, route: { rev: 1 } });
|
||||
await open();
|
||||
out.routeDraft = counts();
|
||||
pipe.route = null;
|
||||
// 관 초안 없음 — 낡음 없음 = 부탁 없음
|
||||
Object.assign(pipe, { crosses: 0, forced: 0 });
|
||||
previewAt = null;
|
||||
await open();
|
||||
out.noDraft = { sum: sum(), ...counts() };
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error && error.stack ? error.stack : error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/* 390-5 스냅 간격 = 조작 단위 — 방향키 걸음(`KeyNudge.arrowStep`) · 끌기 미리보기(`StationFit.fitDrag`) ·
|
||||
* MC 측점 값 자릿수(`Frame.snapCurveStations` · 눈금 글 `stationLabel`)를 Node 로 돌려 JSON 한 줄
|
||||
* (`test_390_5_snap_step_ops.py` 가 판정 · 파이썬 짝 거울은 그쪽).
|
||||
* node helper_390_5_snap_step_ops.cjs '<{"grid":[…],"spans":[[bc,ec]…]}>'
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
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) =>
|
||||
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 LOGIC = path.join(ROOT, "B05_Profile", "logic");
|
||||
const fit = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_StationFit.ts"));
|
||||
const curve = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Curve.ts"));
|
||||
const frame = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Frame.ts"));
|
||||
const nudge = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_KeyNudge.ts"));
|
||||
const station = loadTs(path.join(ROOT, "common_util", "common_util_station.ts"));
|
||||
|
||||
const INTERVAL = 20;
|
||||
const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: INTERVAL };
|
||||
const ROUTE = [
|
||||
[0, 0],
|
||||
[93, 7],
|
||||
[171, 61],
|
||||
[262, 48],
|
||||
[337, 112],
|
||||
[431, 101],
|
||||
[503, 160],
|
||||
];
|
||||
const STEPS = [0.1, 1, 10, INTERVAL];
|
||||
|
||||
function columns(planned) {
|
||||
const n = planned.length;
|
||||
return {
|
||||
planned,
|
||||
curveOn: planned.map(() => true),
|
||||
curveRadius: planned.map(() => null),
|
||||
curveLock: planned.map(() => null),
|
||||
curveArc: planned.map(() => null),
|
||||
apexLock: planned.map((_, i) => i === 0 || i === n - 1),
|
||||
};
|
||||
}
|
||||
|
||||
const build = (planned) =>
|
||||
curve.buildEditedPolyline(
|
||||
planned,
|
||||
planned.map(() => true),
|
||||
planned.map(() => null),
|
||||
LIMITS.minRadiusM,
|
||||
);
|
||||
|
||||
function mcs(planned) {
|
||||
const built = build(planned);
|
||||
return frame.curveSpans(built.vertices, built.curves).map(([a, b]) => (a + b) / 2);
|
||||
}
|
||||
|
||||
const residue = (mc, step) => Math.abs(mc - Math.round(mc / step) * step);
|
||||
const moved = (a, b) => Math.max(...a.map((v, i) => Math.hypot(v[0] - b[i][0], v[1] - b[i][1])));
|
||||
|
||||
// ① 방향키 — 화면 축이 30° 돌고 1 px = 0.5 m 인 지도
|
||||
const turn = Math.PI / 6;
|
||||
const metricAt = (x, y) => [
|
||||
100 + 0.5 * (x * Math.cos(turn) - y * Math.sin(turn)),
|
||||
200 + 0.5 * (x * Math.sin(turn) + y * Math.cos(turn)),
|
||||
];
|
||||
const len = ([x, y]) => Math.hypot(x, y);
|
||||
const keys = {
|
||||
off: len(nudge.arrowStep("ArrowUp", false, metricAt)),
|
||||
offShift: len(nudge.arrowStep("ArrowUp", true, metricAt)),
|
||||
snap: Object.fromEntries(
|
||||
STEPS.map((step) => [
|
||||
step,
|
||||
{
|
||||
plain: len(nudge.arrowStep("ArrowRight", false, metricAt, step)),
|
||||
shift: len(nudge.arrowStep("ArrowRight", true, metricAt, step)),
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
// ② 끌기 미리보기 — 측점 No. 로 맞춘 노선에서 IP 3 을 끌어 감(간격별)
|
||||
const base = fit.fitCurveStations(columns(ROUTE), LIMITS, 1);
|
||||
const DRAG = 3;
|
||||
const mouse = [base[DRAG][0] + 7.3, base[DRAG][1] + 4.1];
|
||||
const drag = Object.fromEntries(
|
||||
STEPS.map((step) => {
|
||||
const limits = { ...LIMITS, intervalM: step };
|
||||
const now = base.map((v, i) => (i === DRAG ? mouse : v));
|
||||
const preview = fit.fitDrag(columns(now), base, DRAG, limits);
|
||||
// 앞 프레임이 다른 자리를 이미 밀어 둔 상태에서도 같은 값(쌓이지 않음)
|
||||
const stale = preview.map((v, i) => (i === DRAG ? mouse : [v[0] + 0.7, v[1] - 0.4]));
|
||||
const again = fit.fitDrag(columns(stale), base, DRAG, limits);
|
||||
// 놓을 때 정본 맞춤(`applyEdit` = fitStart → fitCurveStations)이 미리보기를 안 바꿈
|
||||
const from = fit.fitStart(columns(base), columns(preview));
|
||||
const release = fit.fitCurveStations(columns(preview), limits, from);
|
||||
const after = mcs(preview);
|
||||
const built = build(preview);
|
||||
return [
|
||||
step,
|
||||
{
|
||||
residues: after.map((mc) => residue(mc, step)),
|
||||
tol: fit.mcTolM(step),
|
||||
off: fit.offStationCount(built.vertices, built.curves, step),
|
||||
dragMoved: Math.hypot(preview[DRAG][0] - mouse[0], preview[DRAG][1] - mouse[1]),
|
||||
stale: moved(preview, again),
|
||||
release: moved(preview, release),
|
||||
before: moved(base.slice(0, DRAG - 1), preview.slice(0, DRAG - 1)),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
// ③ MC 측점 값 · 눈금 글
|
||||
const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}');
|
||||
const snapped = Object.fromEntries(
|
||||
[
|
||||
["0.1", 0.1],
|
||||
["1", 1],
|
||||
["10", 10],
|
||||
["station", null],
|
||||
].map(([key, step]) => [key, frame.snapCurveStations(input.grid, input.spans, step)]),
|
||||
);
|
||||
const labels = {
|
||||
int: station.stationLabel(119.8, 20),
|
||||
one: station.stationLabel(119.8, 20, 1),
|
||||
oneWhole: station.stationLabel(120, 20, 1),
|
||||
oneSmall: station.stationLabel(100.1, 20, 1),
|
||||
oneDefault: station.stationLabel(119.84, 20),
|
||||
};
|
||||
|
||||
console.log(
|
||||
JSON.stringify({ keys, drag, snapped, labels, scale: [0.1, 1, 10, null].map(frame.mcScale) }),
|
||||
);
|
||||
@@ -117,4 +117,4 @@ def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None:
|
||||
|
||||
def test_apply_saves_station_snap_on() -> None:
|
||||
text = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
|
||||
assert "routeDraftOf(nodes, { ...settings, stationSnap: true })" in text
|
||||
assert "routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null })" in text
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""387-8 종단 판이 접혀도(그리기 낡음 판정 없음) 관 초안 둔 채 다시 열기 = 펼친 때 값 — 미리 뜬 측점 안 섞임.
|
||||
|
||||
· 관 초안 자리 미리 뜬 횡단(서버가 저장 계획선 = 옛 계획고로 셈)이 묶음 cross 뒤에 얹히면 상세 가게가 묶음이 다 돈 뒤
|
||||
화면 선 기준 낡음을 보고 cross 한 번 더(낡음 판정 = 가게 몫 · 원칙 4 — 보기가 숨어도 계산 · 판정은 돎)
|
||||
· 같은 낡음 = 한 번만 · 묶음이 아직 안 섬 · 노선 초안 = 안 부탁
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_387_8_folded_profile.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).parent / "helper_387_8_folded_profile.cjs"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_접힌_종단_판_다시_열기_펼친_때와_같음(out: dict) -> None:
|
||||
assert out["folded"]["mixed"] == [] and out["expanded"]["mixed"] == []
|
||||
assert out["folded"]["sum"] == out["expanded"]["sum"]
|
||||
assert (out["folded"]["crosses"], out["folded"]["forced"]) == (1, 1)
|
||||
|
||||
|
||||
def test_되풀이_없음(out: dict) -> None:
|
||||
assert out["again"] == {"crosses": 1, "forced": 1} # 다시 읽어도 더 안 부탁
|
||||
assert out["unfixed"] == {"crosses": 1, "forced": 1} # 못 고친 낡음 = 한 번만
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["notStarted", "routeDraft", "noDraft"])
|
||||
def test_안_부탁(out: dict, case: str) -> None:
|
||||
assert (out[case]["crosses"], out[case]["forced"]) == (0, 0)
|
||||
|
||||
|
||||
def test_부르는_곳() -> None:
|
||||
store = (ROOT / "B06_Section/B06_Section_Section_Store.ts").read_text(encoding="utf-8")
|
||||
walls = store.split("async function withDraftWalls", 1)[1].split("\n}\n", 1)[0]
|
||||
assert "recrossSaved(projectId, keyOf(projectId, routeId), detail, routeId);" in walls
|
||||
body = store.split("function recrossSaved", 1)[1].split("\n}\n", 1)[0]
|
||||
assert "staleKeyAgainstScreen(detail, staleOf)" in body and "askedStale" in body
|
||||
# 종단 판 그리기는 기다림 표시만(다시 셈 부탁 없음 · 387-4)
|
||||
preview = (ROOT / "B05_Profile/B05_Profile_UI_Profile_Preview.ts").read_text(encoding="utf-8")
|
||||
assert "forceCross" not in preview
|
||||
@@ -58,6 +58,6 @@ def test_간격별_상태줄_글(texts, step, want) -> None:
|
||||
def test_편집기는_고른_간격_글을_씀() -> None:
|
||||
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
|
||||
assert "일반측점에 맞췄" not in edit
|
||||
assert edit.count("applyEdit(snapStepM.text())") == 2
|
||||
assert edit.count("applyEdit(snap.text())") == 2
|
||||
snap = SNAP.read_text(encoding="utf-8")
|
||||
assert "text: () => snapFitText(select.value)" in snap
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""390-5 스냅 간격 = 조작 단위(요구 243) — 스냅 켬이면 방향키 한 번 = 고른 간격 · 끄는 동안 MC 가
|
||||
간격 배수에 맞은 자리로 보임 · MC 측점 값 = 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 = 정수) ·
|
||||
간격을 저장 노선(곡선표 `station_snap_step`)에 같이 → 서버 측점 셈(파이썬 짝)이 같은 값.
|
||||
|
||||
TS 셈은 `helper_390_5_snap_step_ops.cjs`(Node) · 파이썬 짝 거울 · 저장 왕복 · 편집기 이음은 여기.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_390_5_snap_step_ops.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap_step # noqa: E402
|
||||
from B05_Profile.B05_Profile_Route_Save import RouteDraftInput, RouteSaveRequest # noqa: E402
|
||||
from B05_Profile.B05_Profile_Router_Replan import _write_planned_polyline # noqa: E402
|
||||
from common_util.common_util_route_geometry import planned_route_working_path # noqa: E402
|
||||
|
||||
B05 = ROOT / "B05_Profile"
|
||||
HELPER = Path(__file__).with_name("helper_390_5_snap_step_ops.cjs")
|
||||
GRID = [float(v) for v in range(0, 1001, 20)]
|
||||
STEPS = {"0.1": 0.1, "1": 1.0, "10": 10.0, "station": None}
|
||||
|
||||
|
||||
def _spans() -> list[list[float]]:
|
||||
rng = random.Random(3905)
|
||||
spans, at = [], 15.0
|
||||
while at < 950:
|
||||
start = round(at + rng.uniform(0, 30), 3)
|
||||
end = round(start + rng.uniform(18, 60), 3)
|
||||
spans.append([start, end])
|
||||
at = end + rng.uniform(5, 25)
|
||||
return spans
|
||||
|
||||
|
||||
SPANS = _spans()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER), json.dumps({"grid": GRID, "spans": SPANS})],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
# ── ① 방향키 ──
|
||||
|
||||
|
||||
def test_스냅_끔_방향키는_0_1_Shift_1(got) -> None:
|
||||
assert got["keys"]["off"] == pytest.approx(0.1)
|
||||
assert got["keys"]["offShift"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
|
||||
def test_스냅_켬_방향키_한_번_고른_간격_Shift_무시(got, step) -> None:
|
||||
key = got["keys"]["snap"][step]
|
||||
assert key["plain"] == pytest.approx(float(step))
|
||||
assert key["shift"] == pytest.approx(float(step))
|
||||
|
||||
|
||||
# ── ② 끌기 미리보기 ──
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
|
||||
def test_끄는_동안_MC_가_간격_배수(got, step) -> None:
|
||||
drag = got["drag"][step]
|
||||
assert drag["off"] == 0
|
||||
assert max(drag["residues"]) <= drag["tol"]
|
||||
assert drag["dragMoved"] <= float(step) / 2 + 1e-6 # 끈 IP 는 접선 따라 ±간격/2 안만
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
|
||||
def test_미리보기는_쌓이지_않고_놓을_때와_같음(got, step) -> None:
|
||||
drag = got["drag"][step]
|
||||
assert drag["stale"] == 0
|
||||
assert drag["release"] == 0
|
||||
assert drag["before"] == 0 # 끈 IP 앞앞 IP 는 그대로
|
||||
|
||||
|
||||
def test_끌기_방향키_편집기_이음() -> None:
|
||||
pointer = (B05 / "B05_Profile_UI_RouteEdit_Pointer.ts").read_text(encoding="utf-8")
|
||||
keys = (B05 / "B05_Profile_UI_RouteEdit_Keys.ts").read_text(encoding="utf-8")
|
||||
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
|
||||
assert "params.fitDrag(dragNode)" in pointer
|
||||
assert "params.fitDrag(now.index)" in keys and "params.snapStepM()" in keys
|
||||
assert "fit.fitDrag(snapshotNow(), base, index, fitLimits())" in edit
|
||||
assert edit.count("\n") <= 695
|
||||
|
||||
|
||||
# ── ③ MC 측점 값 · 파이썬 짝 ──
|
||||
|
||||
|
||||
def test_자릿수_배수(got) -> None:
|
||||
assert got["scale"] == [10, 1, 1, 1]
|
||||
assert [core._mc_scale(v) for v in (0.1, 1.0, 10.0, None)] == [10, 1, 1, 1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", list(STEPS))
|
||||
def test_MC_측점_값_자릿수(got, key) -> None:
|
||||
values = got["snapped"][key]
|
||||
moved = [v for v in values if v not in GRID]
|
||||
assert moved, "호 안 측점이 MC 로 옮겨져야 함"
|
||||
scale = 10 if key == "0.1" else 1
|
||||
assert all(abs(v * scale - round(v * scale)) < 1e-9 for v in values)
|
||||
if key == "0.1":
|
||||
assert any(abs(v - round(v)) > 1e-9 for v in moved) # 소수 한 자리 MC 가 실제로 생김
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", list(STEPS))
|
||||
def test_파이썬_짝_거울(got, key) -> None:
|
||||
py = [float(v) for v in core.snap_curve_stations(GRID, SPANS, STEPS[key])]
|
||||
assert py == got["snapped"][key]
|
||||
|
||||
|
||||
def test_옛_부르기는_정수_그대로() -> None:
|
||||
old = [float(v) for v in core.snap_curve_stations(GRID, SPANS)]
|
||||
assert old == [float(v) for v in core.snap_curve_stations(GRID, SPANS, None)]
|
||||
assert all(v == round(v) for v in old)
|
||||
|
||||
|
||||
def test_눈금_글_0_1_때만_소수_한_자리(got) -> None:
|
||||
labels = got["labels"]
|
||||
assert labels["int"] == "5+20" and labels["oneDefault"] == "5+20" # 다른 화면 = 정수(09-29)
|
||||
assert labels["one"] == "5+19.8" and labels["oneSmall"] == "5+0.1"
|
||||
assert labels["oneWhole"] == "6+0"
|
||||
overlay = (ROOT / "B04_PreProcess" / "B04_PreProcess_UI_MapOverlays.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "stationLabel(chainage, interval, options.chainages ? 1 : 0)" in overlay
|
||||
|
||||
|
||||
# ── 저장 왕복 ──
|
||||
|
||||
|
||||
def test_초안_간격이_요청으로_펴짐() -> None:
|
||||
nodes = [(0.0, 0.0), (50.0, 10.0), (100.0, 0.0)]
|
||||
draft = RouteDraftInput(nodes=nodes, stationSnap=True, stationSnapStep=0.1)
|
||||
resolved = RouteSaveRequest(draft=draft).resolved()
|
||||
assert resolved.station_snap is True and resolved.station_snap_step == 0.1
|
||||
old = RouteSaveRequest(draft=RouteDraftInput(nodes=nodes, stationSnap=True))
|
||||
assert old.resolved().station_snap_step is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("snap", "step", "want"),
|
||||
[
|
||||
(True, 0.1, 0.1),
|
||||
(True, 1.0, 1.0),
|
||||
(True, 10.0, 10.0),
|
||||
(True, None, None),
|
||||
(False, 0.1, None),
|
||||
],
|
||||
)
|
||||
def test_곡선표_저장_왕복(tmp_path, snap, step, want) -> None:
|
||||
path = planned_route_working_path(tmp_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
points = [(0.0, 0.0), (120.0, 15.0), (240.0, -10.0), (360.0, 30.0)]
|
||||
_write_planned_polyline(path, points, 30.0, station_snap=snap, station_snap_step=step)
|
||||
assert load_station_snap_step(tmp_path) == want
|
||||
|
||||
|
||||
def test_옛_곡선표는_측점_No(tmp_path) -> None:
|
||||
path = planned_route_working_path(tmp_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("x,y\n0,0\n10,0\n", encoding="utf-8")
|
||||
path.with_name(f"{path.stem}_curves.json").write_text(
|
||||
json.dumps({"curves": [], "station_snap": True}), encoding="utf-8"
|
||||
)
|
||||
assert load_station_snap_step(tmp_path) is None
|
||||
|
||||
|
||||
def test_서버_측점_셈이_저장_간격을_씀() -> None:
|
||||
sections = (B05 / "B05_Profile_Engine_Sections.py").read_text(encoding="utf-8")
|
||||
assert sections.count("station_snap_step=load_station_snap_step(project_root)") == 2
|
||||
design = ROOT / "A00_Common" / "design"
|
||||
page = (design / "design_pipeline_page.ts").read_text(encoding="utf-8")
|
||||
assert "stationSnapStep: routePlan.station_snap_step" in page
|
||||
stage = (design / "logic" / "design_pipeline_stations.ts").read_text(encoding="utf-8")
|
||||
assert "stationSnapStep: ctx.inputs.route?.stationSnapStep" in stage
|
||||
apply = (B05 / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
|
||||
assert "stationSnap: true, stationSnapStep: null" in apply # 추천 = 측점 No.
|
||||
Reference in New Issue
Block a user