Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into sub_laptop_4

This commit is contained in:
2026-10-18 18:48:48 +09:00
23 changed files with 577 additions and 36 deletions
+7 -8
View File
@@ -27,7 +27,7 @@ import { createDesignPipeline, publishToStore, type DesignPipeline } from "./log
import { SOURCE_INPUT } from "./logic/design_pipeline_types"; import { SOURCE_INPUT } from "./logic/design_pipeline_types";
export { pipelineWork } from "./logic/design_pipeline"; export { pipelineWork } from "./logic/design_pipeline";
import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross"; import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross";
import { createStationsStage, stationDiff } from "./logic/design_pipeline_stations"; import { createStationsStage, snapOptions, stationDiff } from "./logic/design_pipeline_stations";
import type { import type {
ChangeSource, ChangeSource,
FollowPatch, FollowPatch,
@@ -298,12 +298,7 @@ function createPagePipeline(
route.curves, route.curves,
(patch.pipes ?? inputs.pipes ?? []) as PlanPipe[], (patch.pipes ?? inputs.pipes ?? []) as PlanPipe[],
(patch.structures ?? inputs.structures ?? []) as PlanStructure[], (patch.structures ?? inputs.structures ?? []) as PlanStructure[],
{ { intervalM: options.stationIntervalM, types: enabled, ...snapOptions(inputs.route, plan) },
intervalM: options.stationIntervalM,
types: enabled,
stationSnap: inputs.route?.stationSnap ?? false,
stationSnapStep: inputs.route?.stationSnapStep,
},
).map((station) => station.chainageM), ).map((station) => station.chainageM),
}); });
/** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */ /** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */
@@ -342,7 +337,11 @@ function createPagePipeline(
}; };
return followStage({ ...ctx, inputs }); return followStage({ ...ctx, inputs });
}, },
stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => enabled }), stations: createStationsStage({
intervalM: options.stationIntervalM,
types: () => enabled,
saved: () => plan,
}),
ground: createGroundStage(groundDeps), ground: createGroundStage(groundDeps),
planline: createPlanlineStage({ planline: createPlanlineStage({
savedBase: (at) => options.plantedBase?.(savedSource(), at, types) ?? savedSource().base, savedBase: (at) => options.plantedBase?.(savedSource(), at, types) ?? savedSource().base,
@@ -17,6 +17,7 @@ import type {
import type { import type {
PipelineRunRecord, PipelineRunRecord,
PipelineStation, PipelineStation,
RouteDraft,
StageContext, StageContext,
StageFn, StageFn,
} from "./design_pipeline_types"; } from "./design_pipeline_types";
@@ -41,12 +42,30 @@ export function reuseOutside(
}); });
} }
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 노선 초안 스냅. */ /** 저장 노선의 측점 스냅 설정(`RoutePlanResponse` 칸). */
export interface SavedSnap {
station_snap?: boolean;
station_snap_step?: number | null;
}
/** 측점 스냅 설정 — 노선 초안이 있으면 그 값 · 없으면 저장 노선 값(441 · 저장 스냅 노선 측점 어긋남). */
export function snapOptions(
draft: RouteDraft | null | undefined,
saved: SavedSnap | null | undefined,
): Pick<StationsOptions, "stationSnap" | "stationSnapStep"> {
return draft
? { stationSnap: draft.stationSnap, stationSnapStep: draft.stationSnapStep }
: { stationSnap: saved?.station_snap ?? false, stationSnapStep: saved?.station_snap_step };
}
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */
export function createStationsStage( export function createStationsStage(
options: Omit<StationsOptions, "stationSnap" | "stationSnapStep" | "types"> & { options: Omit<StationsOptions, "stationSnap" | "stationSnapStep" | "types"> & {
types: () => ReadonlyArray<PlanType>; types: () => ReadonlyArray<PlanType>;
saved?: () => SavedSnap | null;
}, },
): StageFn<"stations"> { ): StageFn<"stations"> {
const { saved, ...rest } = options;
return (ctx: StageContext<"stations">) => { return (ctx: StageContext<"stations">) => {
const route = ctx.out.route; const route = ctx.out.route;
if (!route) return []; if (!route) return [];
@@ -55,12 +74,7 @@ export function createStationsStage(
route.curves, route.curves,
(ctx.inputs.pipes as PlanPipe[] | null) ?? [], (ctx.inputs.pipes as PlanPipe[] | null) ?? [],
(ctx.inputs.structures as PlanStructure[] | null) ?? [], (ctx.inputs.structures as PlanStructure[] | null) ?? [],
{ { ...rest, types: options.types(), ...snapOptions(ctx.inputs.route, saved?.()) },
...options,
types: options.types(),
stationSnap: ctx.inputs.route?.stationSnap ?? false,
stationSnapStep: ctx.inputs.route?.stationSnapStep,
},
); );
return reuseOutside(next, ctx.prev, ctx.range); return reuseOutside(next, ctx.prev, ctx.range);
}; };
+1
View File
@@ -544,6 +544,7 @@ export async function mountRouteEditor(
if (base) markEdited(); if (base) markEdited();
return base !== undefined; return base !== undefined;
}, },
offStation: () => (stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0),
}); });
bindNodeSplice({ bindNodeSplice({
+14 -1
View File
@@ -31,6 +31,8 @@ interface Hold {
start: Vertex; start: Vertex;
/** 스냅 미리보기 전 노선(맞추기 전) — 다음 걸음 · 하한 검사는 여기서(맞춤이 쌓이지 않음). */ /** 스냅 미리보기 전 노선(맞추기 전) — 다음 걸음 · 하한 검사는 여기서(맞춤이 쌓이지 않음). */
raw: Vertex[] | null; raw: Vertex[] | null;
/** 누를 때 측점 밖 곡선 수(441) — 미리보기가 이보다 늘면 그 걸음 되돌림. */
off: number;
moved: boolean; moved: boolean;
} }
@@ -74,8 +76,18 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
params.draw(); params.draw();
return; return;
} }
now.moved = true;
fitPreview(now, state.planned); fitPreview(now, state.planned);
if (now.raw && params.offStation() > now.off) {
// 맞춰도 MC 가 측점 밖에 남는 자리 — 끌기와 같게 그 걸음만 되돌림(441)
const planned = params.state().planned;
now.raw.forEach((vertex, index) => (planned[index] = vertex));
planned[now.index] = node;
fitPreview(now, planned);
status.textContent = `${params.routeHead()} — 곡선 가운데를 측점에 못 맞춰 더 못 옮깁니다.`;
params.draw();
return;
}
now.moved = true;
status.textContent = `${params.routeHead()} — 방향키로 옮기는 중(뗄 때 한 걸음). ${params.curveHint()}`; status.textContent = `${params.routeHead()} — 방향키로 옮기는 중(뗄 때 한 걸음). ${params.curveHint()}`;
params.draw(); params.draw();
} }
@@ -123,6 +135,7 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
before: state.planned.map(([x, y]): Vertex => [x, y]), before: state.planned.map(([x, y]): Vertex => [x, y]),
start: [node[0], node[1]], start: [node[0], node[1]],
raw: null, raw: null,
off: params.offStation(),
moved: false, moved: false,
}; };
hold = now; hold = now;
@@ -106,6 +106,8 @@ export interface RouteEditPointerParams {
/** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에 · /** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에 ·
* 맞췄으면 true(끔이면 false · 아무 일 없음). */ * 맞췄으면 true(끔이면 false · 아무 일 없음). */
fitDrag: (index: number) => boolean; fitDrag: (index: number) => boolean;
/** 측점 밖 곡선 수(스냅 끔 = 0 · 441 끌기 · 방향키 막힘). */
offStation: () => number;
} }
export interface RouteEditPointer { export interface RouteEditPointer {
@@ -191,8 +193,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
/** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다 /** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다
* (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */ * (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */
let dragMoved = false; let dragMoved = false;
/** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */ /** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격 밂). */
let dragRaw: Vertex[] | null = null; let dragRaw: Vertex[] | null = null;
/** 누를 때 측점 밖 곡선 수(441) — 미리보기가 이보다 늘면 그 걸음 되돌림(못 맞추는 자리로 못 끎). */
let dragOff = 0;
/** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */ /** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */
const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null);
@@ -227,6 +231,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①). // 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1; dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1;
dragRaw = null; dragRaw = null;
dragOff = params.offStation();
dragHandle = hitNode >= 0 ? null : handleAt(px, py); dragHandle = hitNode >= 0 ? null : handleAt(px, py);
dragMoved = false; dragMoved = false;
if (hitNode >= 0) { if (hitNode >= 0) {
@@ -353,7 +358,6 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④). // **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM); const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
const previous = state.planned[dragNode]; const previous = state.planned[dragNode];
dragMoved = true;
state.planned[dragNode] = toMetric(px, py); state.planned[dragNode] = toMetric(px, py);
params.markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. params.markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
const after = params.state(); const after = params.state();
@@ -372,6 +376,17 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
return; return;
} }
fitPreview(state.planned); // 스냅 켬 = 끄는 동안 MC 를 간격 배수에(390-5 · 놓을 때 정본) fitPreview(state.planned); // 스냅 켬 = 끄는 동안 MC 를 간격 배수에(390-5 · 놓을 때 정본)
if (dragRaw && params.offStation() > dragOff) {
// 맞춰도 MC 가 측점 밖에 남는 자리 — 하한 막힘과 같게 그 걸음만 되돌림(441)
const now = params.state().planned;
dragRaw.forEach((vertex, index) => (now[index] = vertex));
now[dragNode] = previous;
fitPreview(now);
status.textContent = `${params.routeHead()} — 곡선 가운데를 측점에 못 맞춰 더 못 옮깁니다.`;
params.draw();
return;
}
dragMoved = true; // 막힘 검사 뒤 — 막힌 채 놓으면 되돌릴 걸음 없음(433-14)
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`; status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`;
@@ -3,7 +3,7 @@
* 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에(366-3 · 요구 240-11) — 손 편집 평면. * 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에(366-3 · 요구 240-11) — 손 편집 평면.
* *
* 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴: * 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴:
* · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격/2 안에서 * · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격 안에서
* 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠. * 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠.
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로. * · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음. * · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
@@ -113,7 +113,7 @@ function solve(
if (!(length > 1e-9)) return null; if (!(length > 1e-9)) return null;
const ux = (base[0] - before[0]) / length; const ux = (base[0] - before[0]) / length;
const uy = (base[1] - before[1]) / length; const uy = (base[1] - before[1]) / length;
const reach = limits.intervalM / 2; const reach = limits.intervalM; // 한가운데(18+9 ~ 19+1)도 가까운 측점까지(441 · 반 간격이면 못 닿음)
const clamp = (d: number): number => Math.max(-reach, Math.min(reach, d)); const clamp = (d: number): number => Math.max(-reach, Math.min(reach, d));
const at = (d: number) => { const at = (d: number) => {
const next = planned.slice(); const next = planned.slice();
+8 -2
View File
@@ -4,6 +4,7 @@ import asyncio
import hashlib import hashlib
import json import json
import logging import logging
from collections.abc import Awaitable, Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -401,8 +402,10 @@ async def get_section_detail(
async def regenerate_sections( async def regenerate_sections(
project_id: UUID, route_id: int, request: SectionRegenerateRequest project_id: UUID, route_id: int, request: SectionRegenerateRequest
) -> SectionDetailResponse | JSONResponse: ) -> SectionDetailResponse | JSONResponse:
"""표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장하고 상세를 반환한다.""" """표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장 · 상세 반환(사용자 값 유지 · 446)."""
return await regenerate_route_sections(project_id, route_id, request.cross_half_width_m) from B06_Section.B06_Section_Router_Widen import widen_route_sections
return await widen_route_sections(project_id, route_id, request.cross_half_width_m)
async def regenerate_route_sections( async def regenerate_route_sections(
@@ -411,6 +414,7 @@ async def regenerate_route_sections(
cross_half_width_m: float, cross_half_width_m: float,
*, *,
stored_options: dict[str, Any] | None = None, stored_options: dict[str, Any] | None = None,
carry: Callable[[aiomysql.Connection], Awaitable[None]] | None = None,
) -> SectionDetailResponse | JSONResponse: ) -> SectionDetailResponse | JSONResponse:
"""`regenerate_sections` 본체 — `stored_options` 를 주면 DB 최신 종단 옵션 대신 그것을 쓴다. """`regenerate_sections` 본체 — `stored_options` 를 주면 DB 최신 종단 옵션 대신 그것을 쓴다.
@@ -472,6 +476,8 @@ async def regenerate_route_sections(
route_id=route_id, route_id=route_id,
sections=sections["cross_sections"], sections=sections["cross_sections"],
) )
if carry: # 옛 사용자 값을 새 행에 — 같은 트랜잭션(446)
await carry(connection)
await connection.commit() await connection.commit()
except Exception: except Exception:
await connection.rollback() await connection.rollback()
+150
View File
@@ -0,0 +1,150 @@
"""B06 카드 폭 넘김 다시 만들기(446) — 지반 표본을 넓혀 종 · 횡단을 다시 만들되 사용자 값은 그대로.
`regenerate_route_sections` 는 종 · 횡단 행을 지우고 새로 만든다. 같은 노선 · 같은 측점이므로
측점 열쇠(누가거리 0.01 m 안)로 옛 횡단 행의 설계(지반 · 메모 · CAD · 상세 표 · 조절) · 상태를,
옛 종단 행의 사용자 키(표준 횡단면 · 유토 풍선 · 계획 입력 · 상태)를 같은 트랜잭션에서 새 행에
옮겨 담고, 계획고 편집(종단 정본 `profile_alignment`)을 옛 계획선 통째로 되살린 뒤 [저장]과 같은
서버 재계산으로 넓힌 지반 위에 다시 셈. 뒤 단계가 실패하면 종 · 횡단 파일을 옛것으로 되돌림.
"""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
import aiomysql
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B06_Section.B06_Section_Repository import get_longitudinal_section
from B06_Section.B06_Section_Schema import SectionDetailResponse
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
_MATCH_M = 0.01 # 측점 열쇠 — 저장 길(`update_cross_section_design`)과 같은 근사
def carried_longitudinal_data(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]:
"""새 종단 data — 생성 요약 · 옵션은 새것 · 나머지(표준 횡단면 · 유토 · 계획 입력)는 옛것."""
merged = {**old, **new, "options": {**(old.get("options") or {}), **(new.get("options") or {})}}
if "grade_overrides" in old: # 사용자 계획 입력 — 다시 만들기는 늘 빈 값으로 냄
merged["grade_overrides"] = old["grade_overrides"]
return merged
def carried_cross_rows(
old_rows: list[dict[str, Any]], new_rows: list[dict[str, Any]]
) -> tuple[list[tuple[str, str, int]], int]:
"""(새 행 UPDATE 값 [(data, status, id)], 못 옮긴 옛 행 수) — 새 요약 위에 옛 data · 상태."""
updates: list[tuple[str, str, int]] = []
used: set[int] = set()
for row in new_rows:
at = float(row["chainage_m"])
near = [
index
for index, old in enumerate(old_rows)
if abs(float(old["chainage_m"]) - at) < _MATCH_M
]
if not near:
continue
index = max(near, key=lambda i: int(old_rows[i]["id"])) # 같은 측점 둘이면 최신 행
used.update(near)
old = old_rows[index]
data = {**_as_dict(old.get("data")), **_as_dict(row.get("data"))}
updates.append((json.dumps(data, ensure_ascii=False), str(old["status"]), int(row["id"])))
return updates, len(old_rows) - len(used)
def _as_dict(value: Any) -> dict[str, Any]:
if isinstance(value, (str, bytes)):
value = json.loads(value)
return value if isinstance(value, dict) else {}
async def _cross_rows(connection: aiomysql.Connection, route_id: int) -> list[dict[str, Any]]:
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT id, chainage_m, data, status FROM cross_sections"
" WHERE route_id = %s ORDER BY id",
(route_id,),
)
return list(await cursor.fetchall())
async def widen_route_sections(
project_id: UUID, route_id: int, cross_half_width_m: float
) -> SectionDetailResponse | JSONResponse:
"""표본 폭 넘김 다시 만들기 — 사용자 값을 옮겨 담은 뒤 새 상세를 돌려줌."""
from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR
from B05_Profile.B05_Profile_Route_Save import _Backup, _carry_profile, _read_json
from B06_Section.B06_Section_Router import get_section_detail, regenerate_route_sections
from B06_Section.B06_Section_Router_Confirm import _recompute_stored_designs
pool = get_db_pool()
async with pool.acquire() as connection:
old_long = await get_longitudinal_section(connection, project_id, route_id)
old_rows = await _cross_rows(connection, route_id)
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
relative = str((old_long or {}).get("longitudinal_file_path") or "")
old_alignment = (_read_json(project_root / relative) if relative else {}).get(
"profile_alignment"
) or {}
stage_dir = project_root / _STAGE_SUBDIR
backup = _Backup(project_root, [], [stage_dir / "longitudinal", stage_dir / "cross_sections"])
dropped = 0
async def carry(connection: aiomysql.Connection) -> None:
nonlocal dropped
updates, dropped = carried_cross_rows(old_rows, await _cross_rows(connection, route_id))
async with connection.cursor() as cursor:
if updates:
await cursor.executemany(
"UPDATE cross_sections SET data = %s, status = %s WHERE id = %s", updates
)
if old_long:
await cursor.execute(
"SELECT id, data FROM longitudinal_sections WHERE route_id = %s"
" ORDER BY id DESC LIMIT 1",
(route_id,),
)
row = await cursor.fetchone()
data = carried_longitudinal_data(old_long.get("data") or {}, _as_dict(row[1]))
await cursor.execute(
"UPDATE longitudinal_sections SET data = %s, status = %s WHERE id = %s",
(json.dumps(data, ensure_ascii=False), old_long["status"], int(row[0])),
)
try:
made = await regenerate_route_sections(
project_id, route_id, cross_half_width_m, carry=carry
)
if isinstance(made, JSONResponse):
await asyncio.to_thread(backup.restore)
return made
try: # 같은 노선 — `_carry_profile` 의 옛 계획선 통째 갈래(빈 정점열 = 바뀐 구간 없음)
async with pool.acquire() as connection:
new_long = await get_longitudinal_section(connection, project_id, route_id)
new_relative = str((new_long or {}).get("longitudinal_file_path") or "")
await _carry_profile(project_root, new_relative, route_id, old_alignment, [], [])
except Exception as exc: # noqa: BLE001 — 계획고 편집을 잃느니 파일을 옛것으로
logger.exception("B06 표본 넓힘: 계획고 옮김 실패 project_id=%s", project_id)
await asyncio.to_thread(backup.restore)
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"계획고 편집 옮김 실패: {exc}"},
)
finally:
backup.drop()
if dropped:
logger.warning(
"B06 표본 넓힘: 새 측점에 없는 옛 횡단 행 %d건 project_id=%s", dropped, project_id
)
await _recompute_stored_designs(project_id, route_id) # [저장]과 같은 서버 재계산(실패 = 로그)
return await get_section_detail(project_id, route_id)
@@ -3,7 +3,8 @@
* 횡단 카드 고르기 차례(295-1) — 고를 자리가 좁은 것이 위(모달 `pickAt` 과 같은 잣대). * 횡단 카드 고르기 차례(295-1) — 고를 자리가 좁은 것이 위(모달 `pickAt` 과 같은 잣대).
* *
* 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) · * 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) ·
* 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로. * 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로 ·
* 암 경계선 판정(굵은 투명 선)은 판정면 아래 — 구조물 아랫부분을 덮지 않음(440).
* 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님. * 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님.
* 맹암거(306-2) = 안쪽 판정면은 맨 아래(겹친 띠 · 구조물이 위) · 테두리 판정(투명 굵은 선)이 면 판정 맨 위. * 맹암거(306-2) = 안쪽 판정면은 맨 아래(겹친 띠 · 구조물이 위) · 테두리 판정(투명 굵은 선)이 면 판정 맨 위.
* ========================================================================== */ * ========================================================================== */
@@ -15,6 +16,8 @@ import { CENTER_HIT_CLASS } from "./B06_Section_UI_Cross_Center";
const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"]; const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"];
/** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */ /** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */
const LINE_HIT_CLASS = "b06-chart__design-hit"; const LINE_HIT_CLASS = "b06-chart__design-hit";
/** 암 경계선 판정(`_Cross_Design_Pick` `ROCK_HIT_CLASS`) — 판정면 아래(440). */
const ROCK_HIT_CLASS = "b06-chart__rock-hit";
/** 맹암거 몸(그림) · 테두리 판정 클래스(306-2). */ /** 맹암거 몸(그림) · 테두리 판정 클래스(306-2). */
const UNDERDRAIN_GROUP = "b06-chart__underdrain"; const UNDERDRAIN_GROUP = "b06-chart__underdrain";
const UNDERDRAIN_BODY = "b06-chart__underdrain-body"; const UNDERDRAIN_BODY = "b06-chart__underdrain-body";
@@ -69,9 +72,12 @@ export function stackPickTargets(layer: SVGElement): void {
.map((el) => ({ el, area: pointsArea(el.getAttribute("points")) })) .map((el) => ({ el, area: pointsArea(el.getAttribute("points")) }))
.sort((a, b) => b.area - a.area) .sort((a, b) => b.area - a.area)
.map(({ el }) => el); .map(({ el }) => el);
const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS)); const rocks = kids.filter((el) => el.classList.contains(ROCK_HIT_CLASS));
const lines = kids.filter(
(el) => el.classList.contains(LINE_HIT_CLASS) && !el.classList.contains(ROCK_HIT_CLASS),
);
const centers = kids.filter((el) => el.classList.contains(CENTER_HIT_CLASS)); // 중심점 = 맨 위(356-3) const centers = kids.filter((el) => el.classList.contains(CENTER_HIT_CLASS)); // 중심점 = 맨 위(356-3)
layer.append(...surfaces, ...edges, ...lines, ...centers); layer.append(...rocks, ...surfaces, ...edges, ...lines, ...centers);
let down: { x: number; y: number } | null = null; let down: { x: number; y: number } | null = null;
layer.addEventListener( layer.addEventListener(
"pointerdown", "pointerdown",
+16 -4
View File
@@ -1,5 +1,10 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import {
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { regenerateSections, type SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { regenerateSections, type SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { loadSectionDetail } from "./B06_Section_Section_Store"; import { loadSectionDetail } from "./B06_Section_Section_Store";
@@ -21,7 +26,8 @@ export interface SampleWidenerDeps {
* 요청 반폭까지 지반 샘플을 넓히는 함수를 만든다 — 보유 샘플로 충분하면 즉시 true * 요청 반폭까지 지반 샘플을 넓히는 함수를 만든다 — 보유 샘플로 충분하면 즉시 true
* (재계산 없음). 모자라면 그 폭으로 B05부터 재생성(영구 저장)하고 상세를 다시 받는다. * (재계산 없음). 모자라면 그 폭으로 B05부터 재생성(영구 저장)하고 상세를 다시 받는다.
* 느려도 허용(2026-08-06 사용자 확정). 전역 [전체 측점 반영]과 카드 줌 버튼의 반폭 * 느려도 허용(2026-08-06 사용자 확정). 전역 [전체 측점 반영]과 카드 줌 버튼의 반폭
* 확대가 같은 경로를 쓴다(2026-08-23 사용자 지시). * 확대가 같은 경로를 쓴다(2026-08-23 사용자 지시). 다시 만들기 전 확인창 · 서버가 측점 설계 ·
* 계획고 편집 · 유토 풍선을 옮겨 담음(446).
*/ */
export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) => Promise<boolean> { export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) => Promise<boolean> {
let running = false; let running = false;
@@ -31,8 +37,14 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) =
if (widthM <= deps.sampledHalfWidth() + 1e-6) return true; if (widthM <= deps.sampledHalfWidth() + 1e-6) return true;
if (running) return false; // 재생성 중 중복 요청은 무시(버튼 연타). if (running) return false; // 재생성 중 중복 요청은 무시(버튼 연타).
running = true; running = true;
showLoadingOverlay(); let shown = false; // 오버레이는 셈 수 — 취소면 안 내림(남의 오버레이까지 내려감)
try { try {
const ask = L("B06_Profile_Regenerate_Confirm")
.replace("{width}", String(widthM))
.replace("{sampled}", String(deps.sampledHalfWidth()));
if (!(await showConfirmDialog(ask, L("B06_Profile_Regenerate_Go")))) return false;
showLoadingOverlay();
shown = true;
await regenerateSections(target.projectId, target.routeId, widthM); await regenerateSections(target.projectId, target.routeId, widthM);
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다 · 공유 캐시도 // 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다 · 공유 캐시도
// 새 상세로(B05 가 옛 값을 못 보게) · 초안 측점 · 그림 자료도 같은 길(323-4) // 새 상세로(B05 가 옛 값을 못 보게) · 초안 측점 · 그림 자료도 같은 길(323-4)
@@ -46,7 +58,7 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) =
return false; return false;
} finally { } finally {
running = false; running = false;
hideLoadingOverlay(); if (shown) hideLoadingOverlay();
} }
}; };
} }
@@ -17,6 +17,7 @@ const STUBS = {
showLoadingOverlay: () => calls.overlay++, showLoadingOverlay: () => calls.overlay++,
hideLoadingOverlay: () => {}, hideLoadingOverlay: () => {},
showToast: () => {}, showToast: () => {},
showConfirmDialog: async () => true, // 다시 만들기 확인창(446) — 이 시험은 늘 확인
}, },
"./B06_Section_Api_Fetch": { "./B06_Section_Api_Fetch": {
regenerateSections: async (_p, _r, width) => calls.regenerate.push(width), regenerateSections: async (_p, _r, width) => calls.regenerate.push(width),
+6 -1
View File
@@ -95,15 +95,17 @@ async function toggles(projectId, shift) {
const steps = []; const steps = [];
const run = async (tag) => { const run = async (tag) => {
const updated = []; const updated = [];
let calls = 0; // 받는 쪽 = 카드 다시 그림 + 종단 · 유토 다시 그림(design_shared) — 한 걸음에 한 번
const out = await m.refreshSavedCross( const out = await m.refreshSavedCross(
{ projectId, routeId: R, detail, edits: EDITS, alignment }, { projectId, routeId: R, detail, edits: EDITS, alignment },
stations, stations,
(rows) => updated.push(...rows), (rows) => (calls++, updated.push(...rows)),
); );
steps.push({ steps.push({
tag, tag,
out: out ? Object.keys(out.byKey).length : null, out: out ? Object.keys(out.byKey).length : null,
updated, updated,
calls,
...pick(target.design), ...pick(target.design),
}); });
}; };
@@ -126,6 +128,9 @@ async function toggles(projectId, shift) {
m.writeCrossDesignChoice(projectId, R, target.chainage_m, { ...choice }); m.writeCrossDesignChoice(projectId, R, target.chainage_m, { ...choice });
await run(tag); await run(tag);
} }
// 사면 이동 한 걸음(B06 ◀▶ 0.1 m · 깎기 쪽 · 441 · 요구 268-4)
m.writeShoulderExtend(projectId, R, target.chainage_m, job.mode.split("_")[0], 0.1);
await run("slope");
return { target: target.chainage_m, steps }; return { target: target.chainage_m, steps };
} }
@@ -1,5 +1,8 @@
/* PLAN 439 시험 묶음 진입점 — 저장 노선 cross 차례(카드 단추 → 다시 셈 · 측점 어긋남) · 진짜 모듈 · vite ssr. */ /* PLAN 439 시험 묶음 진입점 — 저장 노선 cross 차례(카드 단추 → 다시 셈 · 측점 어긋남) · 진짜 모듈 · vite ssr. */
export { refreshSavedCross } from "../../A00_Common/design/design_shared"; export { refreshSavedCross } from "../../A00_Common/design/design_shared";
export { writeCrossDesignChoice } from "../../B06_Section/B06_Section_Cross_Design_Session"; export {
writeCrossDesignChoice,
writeShoulderExtend,
} from "../../B06_Section/B06_Section_Cross_Design_Session";
export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel"; export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel";
export { writeState } from "../../A00_Common/b_page_state"; export { writeState } from "../../A00_Common/b_page_state";
@@ -78,4 +78,24 @@ out.move = {
same: after.map((s) => byKey.get(s.key) === s), same: after.map((s) => byKey.get(s.key) === s),
empty: run(null, null, null, null), empty: run(null, null, null, null),
}; };
// 441 — 초안 없는 저장 노선 = 저장 스냅 설정 따름 · 초안이 있으면 초안 값(스냅 case = cases[1])
const snapCase = job.cases[1];
const savedRun = (route, saved) =>
stage
.createStationsStage({ types: () => job.types, saved: () => saved })({
generation: 1,
range: null,
signal: new AbortController().signal,
inputs: { route, pipes: job.pipes, structures: job.structures },
out: { route: { line: snapCase.line, curves: snapCase.curves, chainage: [], lengthM: 0 } },
before: {},
prev: null,
})
.map((s) => s.chainageM);
out.savedSnap = {
saved: savedRun(null, { station_snap: true, station_snap_step: null }),
savedOff: savedRun(null, { station_snap: false }),
noPlan: savedRun(null, null),
draftWins: savedRun({ stationSnap: false }, { station_snap: true }),
};
process.stdout.write(JSON.stringify(out)); process.stdout.write(JSON.stringify(out));
@@ -128,6 +128,82 @@ const drag = Object.fromEntries(
}), }),
); );
// ④ 끌기 훑기(441) — 편집 창 끌기 차례(`_Pointer`) 그대로: 하한 막힘 · 미리보기 측점 밖이 누를 때보다 늘면
// 그 걸음 되돌림 · 놓을 때 정본 맞춤(`applyEdit`). 측점 No. · IP 셋 × 12 방향 × 3 거리 · 고정 R(곡선표 반경 못박음).
const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts"));
const copy = (c) => ({
...c,
planned: c.planned.map(([x, y]) => [x, y]),
curveRadius: [...c.curveRadius],
});
function sweep(fixedR) {
const start = columns(fit.fitCurveStations(columns(ROUTE), LIMITS, 1));
if (fixedR)
start.curveRadius = edits
.rebuildLine(copy(start), LIMITS)
.nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100));
const floor = (built) =>
edits.curveShortfalls(built.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM);
const off = (built) => fit.offStationCount(built.vertices, built.curves, INTERVAL);
const tally = { drags: 0, released: 0, offAfter: 0, strayed: 0, worst: [] };
for (const K of [2, 3, 4])
for (let deg = 0; deg < 360; deg += 30)
for (const reach of [9, 17, 26]) {
const hist = copy(start);
let st = copy(hist);
let built = edits.rebuildLine(st, LIMITS);
const offBefore = off(built);
let raw = null;
let movedOnce = false;
const preview = () => {
raw = st.planned.map(([x, y]) => [x, y]);
st = { ...st, planned: fit.fitDrag(st, hist.planned, K, LIMITS) };
built = edits.rebuildLine(st, LIMITS);
};
const p0 = hist.planned[K];
for (let t = 0.5; t <= reach + 1e-9; t += 0.5) {
if (raw) raw.forEach((v, i) => (st.planned[i] = v));
built = edits.rebuildLine(st, LIMITS);
const before = floor(built);
const previous = st.planned[K];
st.planned[K] = [
p0[0] + Math.cos((deg * Math.PI) / 180) * t,
p0[1] + Math.sin((deg * Math.PI) / 180) * t,
];
built = edits.rebuildLine(st, LIMITS);
if (edits.shortfallCrossed(before, floor(built))) {
st.planned[K] = previous;
preview();
continue;
}
preview();
if (off(built) > offBefore) {
raw.forEach((v, i) => (st.planned[i] = v));
st.planned[K] = previous;
preview();
tally.strayed += 1;
continue;
}
movedOnce = true;
}
tally.drags += 1;
if (!movedOnce) continue;
built = edits.rebuildLine(st, LIMITS);
if (edits.floorStop(hist, st.planned, built.nodes, LIMITS)) continue;
const from = fit.fitStart(hist, st);
st = { ...st, planned: fit.fitCurveStations(st, LIMITS, from) };
built = edits.rebuildLine(st, LIMITS);
tally.released += 1;
const left = fit.offStationMcs(built.vertices, built.curves, INTERVAL);
if (left.length) {
tally.offAfter += 1;
if (tally.worst.length < 5) tally.worst.push({ K, deg, reach, mc: left });
}
}
return tally;
}
const swept = { auto: sweep(false), fixed: sweep(true) };
// ③ MC 측점 값 · 눈금 글 // ③ MC 측점 값 · 눈금 글
const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}'); const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}');
const snapped = Object.fromEntries( const snapped = Object.fromEntries(
@@ -147,5 +223,12 @@ const labels = {
}; };
console.log( console.log(
JSON.stringify({ keys, drag, snapped, labels, scale: [0.1, 1, 10, null].map(frame.mcScale) }), JSON.stringify({
keys,
drag,
swept,
snapped,
labels,
scale: [0.1, 1, 10, null].map(frame.mcScale),
}),
); );
@@ -0,0 +1,57 @@
/* 카드 폭 넘김 다시 만들기 확인창(PLAN 446) 단위검증 헬퍼 — `createSampleWidener` 를 트랜스파일해 돌린다.
* 취소 = 재생성 없음 · 오버레이 셈 그대로 · 확인 = 재생성 한 번 · 오버레이 올림 · 내림 짝. 결과는 JSON 한 벌.
* 짝 = `test_446_widen_carry.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const calls = { regenerate: [], overlay: 0, asked: [] };
let answer = false;
const STUBS = {
"@ui/ui_template_locale": {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_t, key) => [`${String(key)} {width}/{sampled}`] }),
},
"@ui/ui_template_elements": {
showLoadingOverlay: () => calls.overlay++,
hideLoadingOverlay: () => calls.overlay--,
showToast: () => {},
showConfirmDialog: async (message) => {
calls.asked.push(message);
return answer;
},
},
"./B06_Section_Api_Fetch": {
regenerateSections: async (_p, _r, width) => calls.regenerate.push(width),
},
"./B06_Section_Section_Store": { loadSectionDetail: async () => ({ cross_sections: [] }) },
};
const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Common.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, (r) => STUBS[r]);
(async () => {
const widen = box.exports.createSampleWidener({
target: () => ({ projectId: "p", routeId: 1 }),
sampledHalfWidth: () => 20,
applyDetail: () => {},
});
const out = {};
out.within = await widen(15); // 표본 안 = 묻지 않음
out.withinAsked = calls.asked.length;
out.cancel = await widen(21);
out.cancelCalls = calls.regenerate.length;
out.cancelOverlay = calls.overlay;
answer = true;
out.ok = await widen(21);
out.okCalls = [...calls.regenerate];
out.okOverlay = calls.overlay;
out.asked = calls.asked;
process.stdout.write(JSON.stringify(out));
})();
@@ -4,6 +4,7 @@
- 저장 노선 cross 차례(`refreshSavedCross`) = 카드 자료(상세 설계)를 **늘** 다시 셈 — - 저장 노선 cross 차례(`refreshSavedCross`) = 카드 자료(상세 설계)를 **늘** 다시 셈 —
복합경사 · 측구 · 지반 단추마다 그 측점 설계선 · 면적이 바뀜. 복합경사 · 측구 · 지반 단추마다 그 측점 설계선 · 면적이 바뀜.
- 묶음 측점 = 상세 측점이면 돌려줌 · 1 mm 넘게 다르면 null(묶음 엔진) — 그래도 카드 자료는 고침. - 묶음 측점 = 상세 측점이면 돌려줌 · 1 mm 넘게 다르면 null(묶음 엔진) — 그래도 카드 자료는 고침.
- 사면 이동 한 걸음도 같음 · 걸음마다 받는 쪽(카드 · 종단 · 유토 다시 그림) 한 번(441 · 요구 268-2 · 4).
- 서버 폴백(선형 저장분 없음)에도 카드 선택이 실림(열쇠 = 측점 자리) · 서버는 저장분 위에 얹음. - 서버 폴백(선형 저장분 없음)에도 카드 선택이 실림(열쇠 = 측점 자리) · 서버는 저장분 위에 얹음.
짝: `helper_266_card_toggle.mjs` · 진입점 `helper_266_card_toggle_entry.ts` · 짝: `helper_266_card_toggle.mjs` · 진입점 `helper_266_card_toggle_entry.ts` ·
지형 · 노선 틀 = `test_346_2_pipeline_cross_page.py`. 지형 · 노선 틀 = `test_346_2_pipeline_cross_page.py`.
@@ -99,7 +100,8 @@ def _check_toggles(case: dict, returned) -> None:
steps = {step["tag"]: step for step in case["steps"]} steps = {step["tag"]: step for step in case["steps"]}
assert steps["open"]["cut"] > 0.5 # 깎기 측점 assert steps["open"]["cut"] > 0.5 # 깎기 측점
before = steps["open"] before = steps["open"]
for tag in ("two_stage", "ditch_off", "soil"): assert all(step["calls"] == 1 for step in case["steps"]) # 걸음마다 다시 그림 한 번(441)
for tag in ("two_stage", "ditch_off", "soil", "slope"):
step = steps[tag] step = steps[tag]
assert case["target"] in step["updated"], tag # 카드 다시 그림 대상 assert case["target"] in step["updated"], tag # 카드 다시 그림 대상
assert step["line"] != before["line"], tag # 카드 설계선 바뀜 assert step["line"] != before["line"], tag # 카드 설계선 바뀜
@@ -264,6 +264,17 @@ def test_스냅_측점은_원래_격자_번호(r):
assert moved and all(abs(a - b) <= 10 for a, b in moved.values()) assert moved and all(abs(a - b) <= 10 for a, b in moved.values())
def test_초안_없으면_저장_스냅_따름(r):
"""441 — 초안 없는 저장 노선 측점 단계 = 저장 station_snap · 초안이 있으면 초안 값."""
snapped = [s["chainageM"] for s in r["cases"][1]]
plain = [s["chainageM"] for s in r["cases"][0]]
assert snapped != plain
got = r["savedSnap"]
assert got["saved"] == snapped
assert got["savedOff"] == plain and got["noPlan"] == plain
assert got["draftWins"] == plain
def test_노선_한_점_옮김_앞_구간_열쇠_그대로(r): def test_노선_한_점_옮김_앞_구간_열쇠_그대로(r):
m = r["move"] m = r["move"]
from_m = _first_changed_m() from_m = _first_changed_m()
+3 -1
View File
@@ -78,7 +78,9 @@ def test_중심점_좌우_없음_위아래만(ts):
def test_배선_걷음(): def test_배선_걷음():
order = (B06 / "B06_Section_UI_Cross_Pick_Order.ts").read_text(encoding="utf-8") order = (B06 / "B06_Section_UI_Cross_Pick_Order.ts").read_text(encoding="utf-8")
assert "layer.append(...surfaces, ...edges, ...lines, ...centers);" in order assert (
"layer.append(...rocks, ...surfaces, ...edges, ...lines, ...centers);" in order
) # 암 = 판정면 아래(440)
for gone in ( for gone in (
ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Shift.ts", ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Shift.ts",
ROOT / "B05_Profile" / "logic" / "B05_Profile_RouteShift.ts", ROOT / "B05_Profile" / "logic" / "B05_Profile_RouteShift.ts",
+21 -2
View File
@@ -80,7 +80,17 @@ def test_끄는_동안_MC_가_간격_배수(got, step) -> None:
drag = got["drag"][step] drag = got["drag"][step]
assert drag["off"] == 0 assert drag["off"] == 0
assert max(drag["residues"]) <= drag["tol"] assert max(drag["residues"]) <= drag["tol"]
assert drag["dragMoved"] <= float(step) / 2 + 1e-6 # 끈 IP 는 접선 따라 ±간격/2 안만 assert drag["dragMoved"] <= float(step) + 1e-6 # 끈 IP 는 접선 따라 ±간격 안만(441)
@pytest.mark.parametrize("mode", ["auto", "fixed"])
def test_끌기_훑기_놓은_뒤_측점_밖_0(got, mode) -> None:
"""441 — 측점 No. 끌기(IP 셋 × 12 방향 × 3 거리) · 놓은 뒤 MC 가 다 측점 위 · 맞춤이 늘 닿음
(반 간격 한도면 18+9 ~ 19+1 에서 못 맞춰 막힘 걸음이 생김)."""
swept = got["swept"][mode]
assert swept["drags"] == 108 and swept["released"] > 0
assert swept["offAfter"] == 0, swept["worst"]
assert swept["strayed"] == 0
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"]) @pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
@@ -101,6 +111,12 @@ def test_끌기_방향키_편집기_이음() -> None:
# 하한 검사는 맞추기 전 노선으로(맞춤이 IP 를 ±간격/2 밀어 10 m · 측점 No. 에서 헛걸림) # 하한 검사는 맞추기 전 노선으로(맞춤이 IP 를 ±간격/2 밀어 10 m · 측점 No. 에서 헛걸림)
assert "dragRaw.forEach((vertex, index) => (state.planned[index] = vertex))" in pointer assert "dragRaw.forEach((vertex, index) => (state.planned[index] = vertex))" in pointer
assert "now.raw.forEach((vertex, index) => (state.planned[index] = vertex))" in keys assert "now.raw.forEach((vertex, index) => (state.planned[index] = vertex))" in keys
# 미리보기 측점 밖이 누를 때보다 늘면 그 걸음 되돌림 · 막힘 검사 뒤에 옮김 표시(441 · 433-14)
assert "params.offStation() > dragOff" in pointer and "params.offStation() > now.off" in keys
move = pointer[pointer.index("const previous = state.planned[dragNode];") :]
assert move.index("shortfallCrossed(") < move.index("params.offStation() > dragOff")
assert move.index("params.offStation() > dragOff") < move.index("dragMoved = true;")
assert "offStation: () => (stationSnap ? fit.offStationCount(" in edit
assert edit.count("\n") <= 695 assert edit.count("\n") <= 695
@@ -193,6 +209,9 @@ def test_서버_측점_셈이_저장_간격을_씀() -> None:
page = (design / "design_pipeline_page.ts").read_text(encoding="utf-8") page = (design / "design_pipeline_page.ts").read_text(encoding="utf-8")
assert "stationSnapStep: routePlan.station_snap_step" in page assert "stationSnapStep: routePlan.station_snap_step" in page
stage = (design / "logic" / "design_pipeline_stations.ts").read_text(encoding="utf-8") stage = (design / "logic" / "design_pipeline_stations.ts").read_text(encoding="utf-8")
assert "stationSnapStep: ctx.inputs.route?.stationSnapStep" in stage assert "...snapOptions(ctx.inputs.route, saved?.())" in stage # 초안 · 없으면 저장 노선(441)
assert "stationSnapStep: draft.stationSnapStep" in stage
assert "stationSnapStep: saved?.station_snap_step" in stage
assert "saved: () => plan" in page and "...snapOptions(inputs.route, plan)" in page
apply = (B05 / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8") apply = (B05 / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
assert "stationSnap: true, stationSnapStep: null" in apply # 추천 = 측점 No. assert "stationSnap: true, stationSnapStep: null" in apply # 추천 = 측점 No.
@@ -4,6 +4,7 @@
(갈아끼운 캐시)에 모든 카드 번호가 있음 · 찾기 넷(카드 고름 · 목록 → 카드 · 넘김 · 풀기)은 지금 그린 상세. (갈아끼운 캐시)에 모든 카드 번호가 있음 · 찾기 넷(카드 고름 · 목록 → 카드 · 넘김 · 풀기)은 지금 그린 상세.
② 평면 패널 빈 곳 누름 = 평면 마커가 고른 것만 풂(`test_409` 의 deselectOther · deselectOwn). ② 평면 패널 빈 곳 누름 = 평면 마커가 고른 것만 풂(`test_409` 의 deselectOther · deselectOwn).
③ 바뀜 신호에서 B06 목록도 자리가 바뀌었으면 다시 실음. ③ 바뀜 신호에서 B06 목록도 자리가 바뀌었으면 다시 실음.
④ 암 경계선 판정(굵은 투명 선)은 구조물 판정면 아래 — 구조물 아랫부분 누름이 구조물을 고름.
TS 셈은 `helper_440_draft_card_ids.cjs`. TS 셈은 `helper_440_draft_card_ids.cjs`.
./venv/Scripts/python.exe -m pytest resources/tester/test_440_cross_draft_pick.py -q ./venv/Scripts/python.exe -m pytest resources/tester/test_440_cross_draft_pick.py -q
@@ -77,3 +78,12 @@ def test_평면_빈_곳_누름은_마커_고름만() -> None:
marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts")
assert "deselect: () => planMarks(canvas)?.picked === ownPick && deselect()," in marks assert "deselect: () => planMarks(canvas)?.picked === ownPick && deselect()," in marks
assert "ownPick = mark.id;" in marks assert "ownPick = mark.id;" in marks
def test_암_경계_판정은_구조물_판정면_아래() -> None:
order = _read("B06_Section", "B06_Section_UI_Cross_Pick_Order.ts")
assert "layer.append(...rocks, ...surfaces, ...edges, ...lines, ...centers);" in order
lines = order[order.index("const lines = kids.filter(") :]
assert "!el.classList.contains(ROCK_HIT_CLASS)" in lines[: lines.index(");")]
pick = _read("B06_Section", "B06_Section_UI_Cross_Design_Pick.ts")
assert 'export const ROCK_HIT_CLASS = "b06-chart__rock-hit";' in pick
+106
View File
@@ -0,0 +1,106 @@
"""446 카드 폭 넘김 다시 만들기 — 표본 폭을 넘겨 종 · 횡단을 다시 만들어도 사용자 값 그대로.
- 횡단 행: 측점 열쇠(0.01 m 안)로 옛 data(설계 · 메모 · CAD · 상세 표 · 조절) · 상태 · 새 요약 이김
- 종단 행: 생성 요약 · 생성 옵션은 새것 · 표준 횡단면 · 유토(풍선) · 계획 입력은 옛것
- 화면: 다시 만들기 전 확인창 · 취소 = 재생성 없음(`helper_446_widen_confirm.cjs`)
"""
import json
import subprocess
from pathlib import Path
import pytest
from B06_Section.B06_Section_Router_Widen import carried_cross_rows, carried_longitudinal_data
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
DESIGN = {
"ground_type": "soil",
"section_mode": "right_cut",
"structure_note": "메모",
"structure_note_cad": {"lines": [1]},
"detail_table": {"rows": 2},
"display_half_width_m": 21,
"basin_adjust": {"d": 0.3},
}
def _old(row_id: int, at: float, design: dict | None = None, status: str = "CONFIRMED") -> dict:
data = {"sample_count": 41, "center_z": 100.0, "design": design or DESIGN}
return {"id": row_id, "chainage_m": at, "data": json.dumps(data), "status": status}
def _new(row_id: int, at: float) -> dict:
return {"id": row_id, "chainage_m": at, "data": json.dumps({"sample_count": 43})}
def test_횡단_행은_측점_열쇠로_설계_상태_그대로() -> None:
old = [_old(1, 0.0), _old(2, 20.0), _old(3, 33.456)]
new = [_new(11, 0.0), _new(12, 20.0), _new(13, 33.46), _new(14, 40.0)]
updates, dropped = carried_cross_rows(old, new)
assert dropped == 0
by_id = {row_id: (json.loads(data), status) for data, status, row_id in updates}
assert set(by_id) == {11, 12, 13} # 40 m = 새 측점 · 옛 값 없음
data, status = by_id[13] # 0.004 m 어긋남도 같은 측점
assert data["design"] == DESIGN and status == "CONFIRMED"
assert data["sample_count"] == 43 and data["center_z"] == 100.0 # 새 요약이 이김 · 옛 칸 남김
def test_같은_측점_둘이면_최신_행_못_옮긴_행은_셈() -> None:
newer = {**DESIGN, "structure_note": "새 메모"}
old = [_old(1, 20.0), _old(5, 20.0, newer, "DRAFT"), _old(6, 55.0)]
updates, dropped = carried_cross_rows(old, [_new(12, 20.0)])
data, status, _ = updates[0]
assert json.loads(data)["design"]["structure_note"] == "새 메모" and status == "DRAFT"
assert dropped == 1 # 55 m — 새 측점에 없음(로그로 알림)
def test_종단_data_는_사용자_키를_옛것으로() -> None:
old = {
"length_m": 500.0,
"options": {"cross_half_width_m": 20, "standard_cross_section": {"road_width_m": 3}},
"grade_overrides": {"max_grade_pct": 12},
"grade_summary": {"old": 1},
"mass_haul": {"balloon_offsets": {"0": [3.0, -4.0]}},
}
new = {
"length_m": 500.0,
"options": {"cross_half_width_m": 21, "station_interval_m": 20},
"grade_overrides": {},
"grade_summary": {"new": 1},
}
merged = carried_longitudinal_data(old, new)
assert merged["options"] == {
"cross_half_width_m": 21,
"station_interval_m": 20,
"standard_cross_section": {"road_width_m": 3},
}
assert merged["grade_overrides"] == {"max_grade_pct": 12}
assert merged["mass_haul"] == {"balloon_offsets": {"0": [3.0, -4.0]}}
assert merged["grade_summary"] == {"new": 1} # 계획고 옮김이 다시 씀
def test_반폭_요청은_옮겨_담는_길로() -> None:
router = (ROOT / "B06_Section" / "B06_Section_Router.py").read_text(encoding="utf-8")
at = router.index("async def regenerate_sections(")
body = router[at : router.index("async def regenerate_route_sections(", at)]
assert "widen_route_sections(" in body and "regenerate_route_sections(" not in body
assert "await carry(connection)" in router # 같은 트랜잭션 안
@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치")
def test_다시_만들기_전_확인창_취소면_재생성_없음() -> None:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_446_widen_confirm.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
)
assert done.returncode == 0, done.stderr
out = json.loads(done.stdout)
assert out["within"] is True and out["withinAsked"] == 0
assert out["cancel"] is False and out["cancelCalls"] == 0 and out["cancelOverlay"] == 0
assert out["ok"] is True and out["okCalls"] == [21] and out["okOverlay"] == 0
assert out["asked"][0].endswith("21/20") # 요청 폭 · 표본 폭이 글에 들어감
+6
View File
@@ -162,4 +162,10 @@ export const ui_locales_b3 = {
"원가계산은 마스터를 쓰는 얼개로 다시 짓는 중입니다 — 표와 계산은 아직 없습니다.", "원가계산은 마스터를 쓰는 얼개로 다시 짓는 중입니다 — 표와 계산은 아직 없습니다.",
"Cost estimation is being rebuilt on the master data — no tables or calculations yet.", "Cost estimation is being rebuilt on the master data — no tables or calculations yet.",
], ],
/* --- B06 카드 폭 넘김 다시 만들기(446) --- */
B06_Profile_Regenerate_Confirm: [
"카드 폭 {width} m 가 지반 표본 폭 {sampled} m 를 넘어 종·횡단을 다시 만듭니다. 측점 설계 · 계획고 편집 · 유토 풍선은 그대로 옮깁니다.",
"View width {width} m exceeds the sampled ground width {sampled} m — sections will be rebuilt. Station designs, grade edits and haul balloons are kept.",
],
B06_Profile_Regenerate_Go: ["다시 만들기", "Rebuild"],
} as const; } as const;