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";
export { pipelineWork } from "./logic/design_pipeline";
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 {
ChangeSource,
FollowPatch,
@@ -298,12 +298,7 @@ function createPagePipeline(
route.curves,
(patch.pipes ?? inputs.pipes ?? []) as PlanPipe[],
(patch.structures ?? inputs.structures ?? []) as PlanStructure[],
{
intervalM: options.stationIntervalM,
types: enabled,
stationSnap: inputs.route?.stationSnap ?? false,
stationSnapStep: inputs.route?.stationSnapStep,
},
{ intervalM: options.stationIntervalM, types: enabled, ...snapOptions(inputs.route, plan) },
).map((station) => station.chainageM),
});
/** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */
@@ -342,7 +337,11 @@ function createPagePipeline(
};
return followStage({ ...ctx, inputs });
},
stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => enabled }),
stations: createStationsStage({
intervalM: options.stationIntervalM,
types: () => enabled,
saved: () => plan,
}),
ground: createGroundStage(groundDeps),
planline: createPlanlineStage({
savedBase: (at) => options.plantedBase?.(savedSource(), at, types) ?? savedSource().base,
@@ -17,6 +17,7 @@ import type {
import type {
PipelineRunRecord,
PipelineStation,
RouteDraft,
StageContext,
StageFn,
} 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(
options: Omit<StationsOptions, "stationSnap" | "stationSnapStep" | "types"> & {
types: () => ReadonlyArray<PlanType>;
saved?: () => SavedSnap | null;
},
): StageFn<"stations"> {
const { saved, ...rest } = options;
return (ctx: StageContext<"stations">) => {
const route = ctx.out.route;
if (!route) return [];
@@ -55,12 +74,7 @@ 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,
stationSnapStep: ctx.inputs.route?.stationSnapStep,
},
{ ...rest, types: options.types(), ...snapOptions(ctx.inputs.route, saved?.()) },
);
return reuseOutside(next, ctx.prev, ctx.range);
};
+1
View File
@@ -544,6 +544,7 @@ export async function mountRouteEditor(
if (base) markEdited();
return base !== undefined;
},
offStation: () => (stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap()) : 0),
});
bindNodeSplice({
+14 -1
View File
@@ -31,6 +31,8 @@ interface Hold {
start: Vertex;
/** 스냅 미리보기 전 노선(맞추기 전) — 다음 걸음 · 하한 검사는 여기서(맞춤이 쌓이지 않음). */
raw: Vertex[] | null;
/** 누를 때 측점 밖 곡선 수(441) — 미리보기가 이보다 늘면 그 걸음 되돌림. */
off: number;
moved: boolean;
}
@@ -74,8 +76,18 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
params.draw();
return;
}
now.moved = true;
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()}`;
params.draw();
}
@@ -123,6 +135,7 @@ export function bindRouteEditKeys(params: RouteEditPointerParams, dragging: () =
before: state.planned.map(([x, y]): Vertex => [x, y]),
start: [node[0], node[1]],
raw: null,
off: params.offStation(),
moved: false,
};
hold = now;
@@ -106,6 +106,8 @@ export interface RouteEditPointerParams {
/** 끄는 중 · 방향키 걸음 미리보기(390-5) — 스냅 켬이면 IP `index` 자리 그대로 MC 를 간격 배수에 ·
* 맞췄으면 true(끔이면 false · 아무 일 없음). */
fitDrag: (index: number) => boolean;
/** 측점 밖 곡선 수(스냅 끔 = 0 · 441 끌기 · 방향키 막힘). */
offStation: () => number;
}
export interface RouteEditPointer {
@@ -191,8 +193,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
/** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다
* (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */
let dragMoved = false;
/** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */
/** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격 밂). */
let dragRaw: Vertex[] | null = null;
/** 누를 때 측점 밖 곡선 수(441) — 미리보기가 이보다 늘면 그 걸음 되돌림(못 맞추는 자리로 못 끎). */
let dragOff = 0;
/** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */
const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null);
@@ -227,6 +231,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1;
dragRaw = null;
dragOff = params.offStation();
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
dragMoved = false;
if (hitNode >= 0) {
@@ -353,7 +358,6 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM);
const previous = state.planned[dragNode];
dragMoved = true;
state.planned[dragNode] = toMetric(px, py);
params.markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
const after = params.state();
@@ -372,6 +376,17 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
return;
}
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 사용자 지적 ②).
status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`;
@@ -3,7 +3,7 @@
* 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에(366-3 · 요구 240-11) — 손 편집 평면.
*
* 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴:
* · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격/2 안에서
* · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격 안에서
* 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠.
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
@@ -113,7 +113,7 @@ function solve(
if (!(length > 1e-9)) return null;
const ux = (base[0] - before[0]) / 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 at = (d: number) => {
const next = planned.slice();
+8 -2
View File
@@ -4,6 +4,7 @@ import asyncio
import hashlib
import json
import logging
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -401,8 +402,10 @@ async def get_section_detail(
async def regenerate_sections(
project_id: UUID, route_id: int, request: SectionRegenerateRequest
) -> SectionDetailResponse | JSONResponse:
"""표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장하고 상세를 반환한다."""
return await regenerate_route_sections(project_id, route_id, request.cross_half_width_m)
"""표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장 · 상세 반환(사용자 값 유지 · 446)."""
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(
@@ -411,6 +414,7 @@ async def regenerate_route_sections(
cross_half_width_m: float,
*,
stored_options: dict[str, Any] | None = None,
carry: Callable[[aiomysql.Connection], Awaitable[None]] | None = None,
) -> SectionDetailResponse | JSONResponse:
"""`regenerate_sections` 본체 — `stored_options` 를 주면 DB 최신 종단 옵션 대신 그것을 쓴다.
@@ -472,6 +476,8 @@ async def regenerate_route_sections(
route_id=route_id,
sections=sections["cross_sections"],
)
if carry: # 옛 사용자 값을 새 행에 — 같은 트랜잭션(446)
await carry(connection)
await connection.commit()
except Exception:
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` 과 같은 잣대).
*
* 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) ·
* 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로.
* 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로 ·
* 암 경계선 판정(굵은 투명 선)은 판정면 아래 — 구조물 아랫부분을 덮지 않음(440).
* 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님.
* 맹암거(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"];
/** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */
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). */
const UNDERDRAIN_GROUP = "b06-chart__underdrain";
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")) }))
.sort((a, b) => b.area - a.area)
.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)
layer.append(...surfaces, ...edges, ...lines, ...centers);
layer.append(...rocks, ...surfaces, ...edges, ...lines, ...centers);
let down: { x: number; y: number } | null = null;
layer.addEventListener(
"pointerdown",
+16 -4
View File
@@ -1,5 +1,10 @@
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 { loadSectionDetail } from "./B06_Section_Section_Store";
@@ -21,7 +26,8 @@ export interface SampleWidenerDeps {
* 요청 반폭까지 지반 샘플을 넓히는 함수를 만든다 — 보유 샘플로 충분하면 즉시 true
* (재계산 없음). 모자라면 그 폭으로 B05부터 재생성(영구 저장)하고 상세를 다시 받는다.
* 느려도 허용(2026-08-06 사용자 확정). 전역 [전체 측점 반영]과 카드 줌 버튼의 반폭
* 확대가 같은 경로를 쓴다(2026-08-23 사용자 지시).
* 확대가 같은 경로를 쓴다(2026-08-23 사용자 지시). 다시 만들기 전 확인창 · 서버가 측점 설계 ·
* 계획고 편집 · 유토 풍선을 옮겨 담음(446).
*/
export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) => Promise<boolean> {
let running = false;
@@ -31,8 +37,14 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) =
if (widthM <= deps.sampledHalfWidth() + 1e-6) return true;
if (running) return false; // 재생성 중 중복 요청은 무시(버튼 연타).
running = true;
showLoadingOverlay();
let shown = false; // 오버레이는 셈 수 — 취소면 안 내림(남의 오버레이까지 내려감)
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);
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다 · 공유 캐시도
// 새 상세로(B05 가 옛 값을 못 보게) · 초안 측점 · 그림 자료도 같은 길(323-4)
@@ -46,7 +58,7 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) =
return false;
} finally {
running = false;
hideLoadingOverlay();
if (shown) hideLoadingOverlay();
}
};
}
@@ -17,6 +17,7 @@ const STUBS = {
showLoadingOverlay: () => calls.overlay++,
hideLoadingOverlay: () => {},
showToast: () => {},
showConfirmDialog: async () => true, // 다시 만들기 확인창(446) — 이 시험은 늘 확인
},
"./B06_Section_Api_Fetch": {
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 run = async (tag) => {
const updated = [];
let calls = 0; // 받는 쪽 = 카드 다시 그림 + 종단 · 유토 다시 그림(design_shared) — 한 걸음에 한 번
const out = await m.refreshSavedCross(
{ projectId, routeId: R, detail, edits: EDITS, alignment },
stations,
(rows) => updated.push(...rows),
(rows) => (calls++, updated.push(...rows)),
);
steps.push({
tag,
out: out ? Object.keys(out.byKey).length : null,
updated,
calls,
...pick(target.design),
});
};
@@ -126,6 +128,9 @@ async function toggles(projectId, shift) {
m.writeCrossDesignChoice(projectId, R, target.chainage_m, { ...choice });
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 };
}
@@ -1,5 +1,8 @@
/* PLAN 439 시험 묶음 진입점 — 저장 노선 cross 차례(카드 단추 → 다시 셈 · 측점 어긋남) · 진짜 모듈 · vite ssr. */
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 { writeState } from "../../A00_Common/b_page_state";
@@ -78,4 +78,24 @@ out.move = {
same: after.map((s) => byKey.get(s.key) === s),
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));
@@ -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 측점 값 · 눈금 글
const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}');
const snapped = Object.fromEntries(
@@ -147,5 +223,12 @@ const labels = {
};
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`) = 카드 자료(상세 설계)를 **늘** 다시 셈 —
복합경사 · 측구 · 지반 단추마다 그 측점 설계선 · 면적이 바뀜.
- 묶음 측점 = 상세 측점이면 돌려줌 · 1 mm 넘게 다르면 null(묶음 엔진) — 그래도 카드 자료는 고침.
- 사면 이동 한 걸음도 같음 · 걸음마다 받는 쪽(카드 · 종단 · 유토 다시 그림) 한 번(441 · 요구 268-2 · 4).
- 서버 폴백(선형 저장분 없음)에도 카드 선택이 실림(열쇠 = 측점 자리) · 서버는 저장분 위에 얹음.
짝: `helper_266_card_toggle.mjs` · 진입점 `helper_266_card_toggle_entry.ts` ·
지형 · 노선 틀 = `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"]}
assert steps["open"]["cut"] > 0.5 # 깎기 측점
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]
assert case["target"] in step["updated"], 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())
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):
m = r["move"]
from_m = _first_changed_m()
+3 -1
View File
@@ -78,7 +78,9 @@ def test_중심점_좌우_없음_위아래만(ts):
def test_배선_걷음():
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 (
ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Shift.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]
assert drag["off"] == 0
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"])
@@ -101,6 +111,12 @@ def test_끌기_방향키_편집기_이음() -> None:
# 하한 검사는 맞추기 전 노선으로(맞춤이 IP 를 ±간격/2 밀어 10 m · 측점 No. 에서 헛걸림)
assert "dragRaw.forEach((vertex, index) => (state.planned[index] = vertex))" in pointer
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
@@ -193,6 +209,9 @@ def test_서버_측점_셈이_저장_간격을_씀() -> None:
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
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")
assert "stationSnap: true, stationSnapStep: null" in apply # 추천 = 측점 No.
@@ -4,6 +4,7 @@
(갈아끼운 캐시)에 모든 카드 번호가 있음 · 찾기 넷(카드 고름 · 목록 → 카드 · 넘김 · 풀기)은 지금 그린 상세.
② 평면 패널 빈 곳 누름 = 평면 마커가 고른 것만 풂(`test_409` 의 deselectOther · deselectOwn).
③ 바뀜 신호에서 B06 목록도 자리가 바뀌었으면 다시 실음.
④ 암 경계선 판정(굵은 투명 선)은 구조물 판정면 아래 — 구조물 아랫부분 누름이 구조물을 고름.
TS 셈은 `helper_440_draft_card_ids.cjs`.
./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")
assert "deselect: () => planMarks(canvas)?.picked === ownPick && deselect()," 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.",
],
/* --- 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;