Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1

This commit is contained in:
2026-10-10 13:40:48 +09:00
9 changed files with 659 additions and 31 deletions
+80 -5
View File
@@ -13,6 +13,8 @@ from uuid import UUID
import aiomysql
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import generate_irregular_sections
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
@@ -181,28 +183,101 @@ async def sync_uphill_overrides_into_designs(
)
#: 반올림만 다른 같은 측점(m) — 서버가 관 측점을 0.01m 로 반올림.
#: 화면 `applyIrregularStations` 0.006 과 짝.
_SAME_STATION_M = 0.006
def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[str, Any] | None:
"""사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다.
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안)만 옮기고 나머지는 지운다.
바뀌었으면 새 등급 요약(DB 몫)을, 아니면 `None`.
"""
stored = data.get("profile_alignment")
if not isinstance(stored, dict) or not stored.get("base_pvi"):
return None
after = [float(station.get("chainage_m", 0.0)) for station in data["stations"]]
after_keys = {chainage_key(value) for value in after}
before_keys = {chainage_key(value) for value in before}
moves: dict[str, str | None] = {}
for old in before:
if chainage_key(old) in after_keys:
continue
near = next(
(
value
for value in after
if abs(value - old) < _SAME_STATION_M and chainage_key(value) not in before_keys
),
None,
)
moves[chainage_key(old)] = None if near is None else chainage_key(near)
edits = stored.get("edits") or {}
next_edits: dict[str, dict[str, Any]] = {}
changed = False
for name in ("station_offsets", "curve_radii"):
values = dict(edits.get(name) or {})
for old_key, new_key in moves.items():
if old_key in values:
value = values.pop(old_key)
if new_key is not None:
values.setdefault(new_key, value)
changed = True
next_edits[name] = values
base: list[dict[str, Any]] = []
for node in stored["base_pvi"]:
key = chainage_key(node["chainage_m"])
if key not in moves:
base.append(node)
continue
changed = True
if moves[key] is not None:
base.append({**node, "chainage_m": float(moves[key])})
if not changed:
return None
stored["base_pvi"] = base
alignment, profile = rebuild_alignment_profile(data, next_edits)
data["profile_alignment"] = alignment
profiles = data.get("design_profiles")
if isinstance(profiles, list) and profiles:
profiles[0] = profile
else:
data["design_profiles"] = [profile]
return {"id": profile["id"], **profile["summary"]}
def _merge_irregular_into_longitudinal(
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
) -> None:
) -> dict[str, Any] | None:
"""종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬).
B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 계획선·규칙 측점·표고
샘플은 손대지 않는다. 파일 기반이라 재확정해도 중복이 생기지 않는다.
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 규칙 측점·표고 샘플은
손대지 않는다. 계획선은 사라진 비정규 측점 자리만 고친다(`_follow_moved_stations` —
바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 재확정해도 중복이 생기지 않는다.
"""
root = project_root.resolve()
path = (root / longitudinal_file_path).resolve()
if root not in path.parents or not path.is_file():
return
return None
data = json.loads(path.read_text(encoding="utf-8"))
stations = data.get("stations")
if not isinstance(stations, list):
return
return None
before = [
float(station.get("chainage_m", 0.0))
for station in stations
if station.get("kind") == "irregular"
]
regular = [station for station in stations if station.get("kind") != "irregular"]
merged = regular + list(irregular_stations)
merged.sort(key=lambda station: float(station.get("chainage_m", 0.0)))
data["stations"] = merged
summary = _follow_moved_stations(data, before)
atomic_write_json(path, data)
return summary
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
+22 -13
View File
@@ -28,7 +28,11 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import get_latest_route, get_surface_crs_epsg
from B05_Profile.B05_Profile_Repository import (
get_latest_route,
get_surface_crs_epsg,
update_longitudinal_grade_summary,
)
from B05_Profile.B05_Profile_Router_Confirm import load_sampling_snapshot
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
@@ -37,14 +41,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Section"])
#: 「그 자리에 측점이 있다」고 볼 거리(m).
#: ⚠⚠ **0.5m 다 — 0.05m 가 아니다**(2026-09-09 실측으로 뒤집힌 자리).
#: 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
#: (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 그래서 관 440.241 은 측점 440.0
#: 위에 서고, 그 측점은 **구조물 이름표까지 달고 있다**(`structure`).
#: 0.05m 로 보면 그런 자리를 「측점 없음」으로 잘못 세어 **있는 측점을 또 만들라고 한다.**
#: 스냅 폭이 「정수 미터 반올림」이므로 최대 어긋남은 0.5m 다.
STATION_MATCH_TOLERANCE_M = 0.5
#: 「그 자리에 측점이 있다」고 볼 거리(m) — 측점 생성의 스냅 폭과 같음
#: (`B05_Profile_Engine_Sections_Core` · 같은 횡단 이름이 되는 0.005m 안만 한 측점).
#: 넓으면 조금 옮긴 관(769.28 → 769.38)을 「측점 있음」으로 세어 측점이 옛 자리에 남음(192-1).
STATION_MATCH_TOLERANCE_M = 0.005
SNAPSHOT_MISSING = (
"지표 샘플링 조건을 찾을 수 없어 측점을 만들 수 없음 — 1단계(지표 확정)를 마친 뒤"
@@ -167,7 +167,7 @@ async def _regenerate_structure_stations(
stored_options, crs_epsg, longitudinal = await run_with_connection(_load)
def _regenerate() -> int:
def _regenerate() -> tuple[int, dict[str, Any] | None]:
polyline = _load_route_polyline(project_root, str(route["route_data_path"]))
pipes = _load_pipe_points(project_root, polyline)
extras = resolve_extra_stations(project_root, pipes)
@@ -181,13 +181,22 @@ async def _regenerate_structure_stations(
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
summary = None
if stations and longitudinal:
_merge_irregular_into_longitudinal(
summary = _merge_irregular_into_longitudinal(
project_root, str(longitudinal["longitudinal_file_path"]), stations
)
return len(stations)
return len(stations), summary
return await asyncio.to_thread(_regenerate)
made, summary = await asyncio.to_thread(_regenerate)
if summary is not None:
# 옮긴 관 자리의 계획선이 다시 셈됨 — DB 등급 요약도 같이(계획선 저장과 같은 길).
await run_with_connection(
lambda connection: update_longitudinal_grade_summary(
connection, route_id=route["id"], grade_summary=summary
)
)
return made
async def ensure_structure_stations(project_id: UUID) -> int:
@@ -8,13 +8,15 @@
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
* 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 ·
* 노폭 안쪽 금지 = 0 에서 멈춤).
* 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 ·
* 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고).
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import { clipSegment, type XY } from "../A00_Common/b_polygon_clip";
import { sideRole } from "@util/common_util_cross_design_geometry";
import {
arrowsBelongToProfile,
bindArrowKeys,
buildPanelShell,
dpadButton,
@@ -100,6 +102,11 @@ export function crossZoneAt(
export const pieceZone = (design: ZoneDesign, piece: DesignPiece) =>
crossZoneAt(design, (piece.from.offset + piece.to.offset) / 2, piece.structure);
/** 고른 선이 움직이는 축(193-1) — 도로측(노면 · 노견) = 높이(y · 종단 계획고 ▲▼) ·
* 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */
export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): "x" | "y" =>
pieceZone(design, piece).zone === "road" ? "y" : "x";
const gap = (a: CrossPoint, b: CrossPoint): number =>
Math.hypot(a.offset - b.offset, a.elevation - b.elevation);
@@ -330,6 +337,7 @@ export function attachDesignLinePick(input: {
label.className = "b06-cross-card__line-dims";
label.hidden = true;
let side: CrossSide | null = null;
let axis: "x" | "y" = "x";
let active: number | null = null;
const { shift } = input;
@@ -364,6 +372,20 @@ export function attachDesignLinePick(input: {
);
shell.root.append(readout, move.row);
const arrows = bindArrowKeys(shell.root, () => [move.controls]);
// 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감.
// 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김.
const blockOffAxis = (event: KeyboardEvent): void => {
if (!shell.root.isConnected) {
document.removeEventListener("keydown", blockOffAxis);
return;
}
if (shell.root.classList.contains("is-hidden") || arrowsBelongToProfile()) return;
const focus = event.target as HTMLElement | null;
if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return;
const vertical = event.key === "ArrowUp" || event.key === "ArrowDown";
const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
if (axis === "x" ? vertical : horizontal) event.preventDefault();
};
return {
pieces,
@@ -375,16 +397,25 @@ export function attachDesignLinePick(input: {
label.hidden = !piece;
active = piece ? index : null;
side = piece ? pieceZone(design, piece).side : null;
const extend = side && shift ? shift.valueOf(side) : 0;
axis = piece ? pieceMoveAxis(design, piece) : "x";
const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0;
label.textContent = piece
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
: "";
const open = !!(piece && shift);
shell.root.classList.toggle("is-hidden", !open);
shell.title.textContent = side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)";
readout.textContent = `이동 ${extend.toFixed(1)}m`;
move.row.classList.toggle("is-hidden", axis === "y");
move.controls.classList.toggle("is-hidden", axis === "y");
shell.title.textContent =
axis === "y" ? "노면 높이" : side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)";
readout.textContent =
axis === "y" ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" : `이동 ${extend.toFixed(1)}m`;
arrows.detach();
if (open) arrows.attach();
document.removeEventListener("keydown", blockOffAxis);
if (open) {
arrows.attach();
document.addEventListener("keydown", blockOffAxis);
}
},
};
}
+13
View File
@@ -0,0 +1,13 @@
# 2026-10-10 계획 192~193장 기록 — 종단 숨은 꺾임(옮긴 관 측점) · 횡단 보라선 이동 축 제약
근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 44 · 45 · 메인 합침 b87b45475.
## 192. 종단 계획선 숨은 꺾임 — 랩탑 메인 프로젝트 No.38+9 자료 확인 (sub7)
정함(브레인) = 사용자 44번 · 사용자 프로젝트 읽기만 · 의심 = 178-1 사면안정군 측점 심음 · 관 지점 · 종단 변곡점(VIP) 중 무엇이 종단 계획선 꼭짓점을 만들었는지
- [x] 192-1 No.38+9 근처 종단 계획선 꼭짓점 · 측점 목록 · 구조물 · 관 자료 대조 → 까닭 판정 · 코드 흠이면 고침(사본 재현) — 새 시험
- 검증완료 — 226fc805 관 769.28 → 769.38(0.1m) 옮김 · 측점 판정 폭 0.5m 라 측점 769.28 에 남음 → 편집 둘(769.28 +0.506 · 769.38 +0.051) · 0.1m 사이 −465% 계단 · 769.38 = 측점선 없는 꼭짓점 · 고침 = 판정 폭 0.005m + 측점 다시 뜰 때 사라진 측점 꼭짓점 · 편집 지움(0.006m 안 옮김) · 사본 760~780 최대 기울기 465 → 20% 미만 · test_192_1(새 5) + missing_stations · 178_1 · 186_1 · 133_2 · 176_3 · patch_station 32건 통과 · typecheck 통과 · 사용자 조작 = B06 「측점 없는 구조물」 줄 [측점 만들기]
## 193. B06 횡단 보라 계획선 끌기 제약 (sub2)
정함(브레인) = 사용자 45번 · 선 갈래 셋: 성토측 · 절토측 · 도로측(노면 · 노견) · 성토측 선 = 도로 중심 기준 성토 방향(바깥)으로만 가로 이동 · 도로 중심 넘지 않음 · 관 길이 때문에 움직일 때 = 반대측 고정 · 그쪽 구조물 + 사면선 전체 한 벌 이동 · 절토측 반대로 같음 · 도로측 선 = Y(높이)만 · 종단 함께(지금 그대로) · 지금 잘 되는 길은 살리고 제약만 더함
- [x] 193-1 끌기 갈래 판정 · 방향 · 축 제약 · 반대측 고정 · 한 벌 이동 — 새 시험(갈래마다 허용 · 막힘)
- 검증완료 — test_193_1_line_axis 8 + test_139_6_line_shift · test_137_design_pick · test_139_6_save_redesign = 24 통과 · typecheck 0 · 사면 ▲▼ 막힘 · 안쪽 30 걸음 = 0 · 반대측 0 그대로 · 노면 ◀▶ 막힘 · ▲▼ 종단도로 넘김
@@ -64,3 +64,5 @@
6. 단, 종단 세로선이나 횡단도를 선택할때는 해당 지점에 대한 하이라이트는 되어야함.
42. B06의 집수정 형상 선택에 대해 조금더 넓은 영역이 선택할 수 있게 변경(날개벽 선택 참고 가상의 선을 그려서 사각 형태의 가상의 영역을 선택영역으로 포함.)
43. B06 횡단도 좌측 패널에서 세부 컨테이너의 정보를 변경하면 해당 구조물 형상을 잘 선택되고 있는데 해당 패널의 컨테이너 영역을 선택해도 변경되게 해줘.
44. 랩탑 메인 프로젝트에서 38+9 측점 확인 종단의 선을 봤을때 중간에 안보이는 가상의 측점이 존재하는것처럼 보임. 데이터로 확인. (사진: 종단 계획선(보라 점선)이 고른 측점 세로선 근처에서 꺾임)
45. 지금 횡단도의 보라색선인 계획선과 관련인데 여기서 선을 찍으면 직선선 성분을 기준으로 이동을 할수 있게 만들어져 있임. 여기서 이동의 제약이 있어야함. 마음대로 변경되고 있음. 보라색 선은 선택은 크게는 성토측, 절토측, 도로측이 있음. 성토측 보라색 선을 선택할경우에는 도로 중심을 기준으로 성토방향으로 만 동작해야함. 만약 배관의 길이 영향으로 갈수 있는 방향은 절토측은 고정이 되고 성토측 방향의 구조물+성토선 전체가 이동이 되어야함. 반대로도 동일함. 이때 노면, 노견의 선을 선택한다면 Y방향으로 이동이되고 이때 종단도도 같이 동작해야함. 지금도 그렇게 구성되어 있음. 전반적으로 잘 구현되어 있으나 다듬을 필요가 있음.
+264
View File
@@ -0,0 +1,264 @@
/* 139-6 고른 설계선 사면 이동 헬퍼 — `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서
* 트랜스파일해 흉내 DOM 으로 돌림 · 151-3 이동 패널(◀ ↺ ▶ · 방향키 ←→). 짝 = `test_139_6_line_shift.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 cache = new Map();
class Node {
constructor(tag) {
this.tagName = tag;
this.children = [];
this.attrs = {};
this.listeners = {};
this.hidden = false;
this.isConnected = true;
this._text = "";
this._cls = new Set();
const self = this;
this.classList = {
add: (...n) => n.forEach((c) => self._cls.add(c)),
toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)),
contains: (c) => self._cls.has(c),
};
}
get descendants() {
return this.children.flatMap((c) => [c, ...c.descendants]);
}
/** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */
querySelector(sel) {
return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null;
}
getClientRects() {
return this._cls.has("is-hidden") ? [] : [{}];
}
set className(v) {
this._cls = new Set(String(v).split(/\s+/).filter(Boolean));
}
set textContent(v) {
this._text = String(v);
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
append(...kids) {
this.children.push(...kids);
}
insertBefore(kid) {
this.children.push(kid);
}
setAttribute(n, v) {
this.attrs[n] = String(v);
if (n === "class") this.className = v;
}
getAttribute(n) {
return this.attrs[n] ?? null;
}
hasAttribute(n) {
return n in this.attrs;
}
addEventListener(t, fn) {
(this.listeners[t] ??= []).push(fn);
}
click() {
(this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined }));
}
}
const docListeners = {};
globalThis.document = {
createElement: (t) => new Node(t),
createElementNS: (_n, t) => new Node(t),
addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn),
removeEventListener: (t, fn) => {
docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn);
},
};
const key = (name) =>
[...(docListeners.keydown ?? [])].forEach((fn) =>
fn({ key: name, target: null, preventDefault: () => undefined }),
);
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 req = (r) =>
r.startsWith("@util/")
? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`))
: loadTs(path.join(path.dirname(full), `${r}.ts`));
new Function("exports", "module", "require", js)(box.exports, box, req);
cache.set(full, box.exports);
return box.exports;
}
globalThis.Element = Node;
const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts"));
const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽
const y = (e) => 900 - (e - 100) * 12;
const line = (pts, structure = false) => {
const el = new Node("polyline");
el.setAttribute("class", "b06-chart__design-cross");
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, "");
return el;
};
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
const press = (name) => {
let prevented = false;
[...(docListeners.keydown ?? [])].forEach((fn) =>
fn({ key: name, target: null, preventDefault: () => (prevented = true) }),
);
return prevented;
};
const pointer = (inProfile) =>
[...(docListeners.pointerdown ?? [])].forEach((fn) =>
fn({ target: Object.assign(new Node("g"), { closest: () => (inProfile ? {} : null) }) }),
);
/** 단면 하나 — 좌 사면 · 노면 · 우 사면(+ 좌 구조물 트림 선). */
function card(sectionMode, ditch) {
for (const k of Object.keys(docListeners)) if (k === "keydown") docListeners[k] = [];
const design = {
road_edges: {
left: { offset_m: 2, elevation_m: 100 },
right: { offset_m: -2, elevation_m: 100 },
},
ditch: ditch ? { type: "standard", top_width_m: 1 } : null,
ditch_side: "left",
ditch_enabled: !!ditch,
section_mode: sectionMode,
};
const layer = new Node("g");
layer.append(
line([
[6, sectionMode === "left_cut" ? 103 : 97],
[2, 100],
]),
line([
[2, 100],
[-2, 100],
]),
line([
[-2, 100],
[-6, 97],
]),
line(
[
[7, 96],
[7, 94],
],
true,
),
);
const values = { left: 0, right: 0 };
const handle = pick.attachDesignLinePick({
layer,
design,
x,
toDisplayY: y,
onPick: () => undefined,
shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) },
});
const at = (offset, structure = false) =>
handle.pieces.findIndex(
(p) =>
p.structure === structure &&
Math.min(p.from.offset, p.to.offset) <= offset &&
offset <= Math.max(p.from.offset, p.to.offset),
);
const slot = (name) => handle.panel.querySelector(`.b06-structure-panel__btn--${name}`);
const title = () =>
handle.panel.descendants.find((n) => n._cls.has("b06-structure-panel__title")).textContent;
const rowHidden = () =>
handle.panel.descendants
.find((n) => n._cls.has("b06-structure-panel__struct"))
._cls.has("is-hidden");
return { design, handle, values, at, slot, title, rowHidden };
}
const out = {};
// ① 성토측(both_fill) — 좌 사면: 가로만 · 바깥만 · 중심 못 넘음 · ▲▼ 막힘
{
const c = card("both_fill", false);
out.axis = {
left: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(4)]),
road: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(0)]),
right: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(-4)]),
structure: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(7, true)]),
};
c.handle.setActive(c.at(4));
const upBlocked = press("ArrowUp");
const downBlocked = press("ArrowDown");
const afterUpDown = { ...c.values };
press("ArrowLeft"); // 화면 왼쪽 = 좌 바깥
press("ArrowLeft");
const out2 = { ...c.values };
for (let i = 0; i < 30; i += 1) press("ArrowRight"); // 안쪽 끝까지 — 0 에서 멈춤
const inward = { ...c.values };
out.fill = {
upBlocked,
downBlocked,
afterUpDown,
out2,
inward,
title: c.title(),
rowHidden: c.rowHidden(),
};
// 종단도를 마지막에 눌렀으면 방향키는 종단도 몫 — 막지 않음
pointer(true);
out.fill.profileUp = press("ArrowUp");
pointer(false);
// ② 구조물 트림 선(성토측) = 같은 쪽 노견째 · 반대측 고정
c.handle.setActive(c.at(7, true));
c.slot("left").click();
out.structure = { values: { ...c.values }, title: c.title(), upBlocked: press("ArrowUp") };
// 우 사면 = 우 바깥(화면 오른쪽)만 · 좌 값 그대로
c.handle.setActive(c.at(-4));
press("ArrowLeft"); // 안쪽 — 0
press("ArrowRight");
out.right = { values: { ...c.values }, title: c.title() };
// ④ 도로측 — 높이만: ▲▼ 는 종단도로 넘김(안 막음) · ◀▶ 막힘 · 값 그대로 · 이동 줄 숨김
const before = { ...c.values };
c.handle.setActive(c.at(0));
out.road = {
upPassed: !press("ArrowUp"),
downPassed: !press("ArrowDown"),
leftBlocked: press("ArrowLeft"),
rightBlocked: press("ArrowRight"),
same: JSON.stringify(before) === JSON.stringify(c.values),
title: c.title(),
rowHidden: c.rowHidden(),
label: c.handle.label.textContent,
};
// 고름 풀면 아무 키도 안 막음
c.handle.setActive(null);
out.released = [press("ArrowUp"), press("ArrowLeft")];
}
// ③ 절토측(left_cut · 좌 = 절토 + 측구) — 같은 규칙 반대로
{
const c = card("left_cut", true);
out.cutZone = pick.pieceZone(c.design, c.handle.pieces[c.at(4)]).zone;
c.handle.setActive(c.at(4));
const upBlocked = press("ArrowUp");
c.slot("left").click(); // 좌 바깥
const outward = { ...c.values };
c.slot("right").click();
c.slot("right").click(); // 안쪽 — 0 에서 멈춤
out.cut = {
upBlocked,
outward,
inward: { ...c.values },
axis: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(4)]),
};
}
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,128 @@
"""192-1 — 종단 계획선 숨은 꺾임(검증_랩탑메인 226fc805 · No.38+9).
까닭: 관을 769.28 → 769.38(0.1m) 옮김 → 「측점 있음」 판정 폭이 0.5m 라 측점이 옛 자리
769.28 에 남음 → B06(측점 769.28)과 B05(관 769.38) 두 자리에 계획고 편집이 남아 0.1m 사이
−465% 계단 · 769.38 은 측점선 없는 꼭짓점.
고침: 판정 폭 0.005m(측점 스냅 폭) · 측점을 다시 뜨면 사라진 측점의 꼭짓점 · 편집을 지움
(반올림 차이 0.006m 안은 옮김). 사용자 storage 는 읽기만 — 사본(tmp_path)으로 재현.
"""
from __future__ import annotations
import copy
import json
import shutil
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import B05_Profile.B05_Profile_Engine_Sections as sections # noqa: E402
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
from B06_Section.B06_Section_Router_Stations import _missing_marks # noqa: E402
SOURCE = (
PROJECT_ROOT
/ "storage/1/3/226fc805-26c8-4f5a-9956-bacb200e10a5/B06_Section/longitudinal/longitudinal.json"
)
REL = "B06_Section/longitudinal/longitudinal.json"
needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="검증_랩탑메인 storage 없음")
def _copy(tmp_path: Path) -> Path:
target = tmp_path / REL
target.parent.mkdir(parents=True)
shutil.copyfile(SOURCE, target)
return target
def _load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _irregular(data: dict, old: float, new: float) -> list[dict]:
"""다시 뜬 비정규 측점 목록 흉내 — `old` 측점만 `new` 로 옮김."""
result = []
for station in data["stations"]:
if station.get("kind") != "irregular":
continue
item = copy.deepcopy(station)
if abs(item["chainage_m"] - old) < 1e-6:
item["chainage_m"] = new
result.append(item)
return result
@needs_copy
def test_원본_자료가_숨은_꼭짓점을_가짐() -> None:
"""까닭 근거 — 769.38 꼭짓점은 측점 목록에 없고 두 편집 사이 −465%."""
data = _load(SOURCE)
alignment = data["profile_alignment"]
stations = [s["chainage_m"] for s in data["stations"]]
pvis = [p["chainage_m"] for p in alignment["pvi"]]
assert 769.38 in pvis and 769.38 not in stations and 769.28 in stations
assert set(alignment["edits"]["station_offsets"]) == {"769.280", "769.380"}
step = next(s for s in alignment["segments"] if s["from_m"] == 769.28)
assert step["to_m"] == 769.38 and step["grade_percent"] < -400
@needs_copy
def test_관_옮긴_측점_다시_뜨면_숨은_꼭짓점_사라짐(tmp_path: Path) -> None:
path = _copy(tmp_path)
data = _load(path)
summary = _merge_irregular_into_longitudinal(tmp_path, REL, _irregular(data, 769.28, 769.38))
after = _load(path)
alignment = after["profile_alignment"]
stations = {round(s["chainage_m"], 3) for s in after["stations"]}
near = [p for p in alignment["pvi"] if 760 < p["chainage_m"] < 780]
assert [p["chainage_m"] for p in near] == [769.38] # 꼭짓점 하나 · 측점선 있음
assert 769.38 in stations and 769.28 not in stations
assert alignment["edits"]["station_offsets"] == {"769.380": pytest.approx(0.051428)}
assert all(abs(b["chainage_m"] - 769.28) > 1e-6 for b in alignment["base_pvi"])
grades = [s["grade_percent"] for s in alignment["segments"] if 760 <= s["from_m"] < 780]
assert max(abs(g) for g in grades) < 20
assert summary is not None and summary["max_grade_pct"] < 20
assert after["design_profiles"][0]["summary"]["max_grade_pct"] == summary["max_grade_pct"]
@needs_copy
def test_반올림_차이만_다르면_편집을_옮김(tmp_path: Path) -> None:
path = _copy(tmp_path)
data = _load(path)
data["profile_alignment"]["edits"]["station_offsets"]["484.210"] = 0.3
path.write_text(json.dumps(data), encoding="utf-8")
_merge_irregular_into_longitudinal(tmp_path, REL, _irregular(data, 484.21, 484.213))
offsets = _load(path)["profile_alignment"]["edits"]["station_offsets"]
assert "484.210" not in offsets and offsets["484.213"] == pytest.approx(0.3)
@needs_copy
def test_측점이_그대로면_계획선을_건드리지_않음(tmp_path: Path) -> None:
path = _copy(tmp_path)
data = _load(path)
before = data["profile_alignment"]
assert _merge_irregular_into_longitudinal(tmp_path, REL, _irregular(data, -1, -1)) is None
assert _load(path)["profile_alignment"] == before
def test_조금_옮긴_관도_측점_없음으로_셈(tmp_path: Path, monkeypatch) -> None:
"""0.1m 옮긴 관 — 옛 판정(0.5m)은 「측점 있음」으로 놓쳤음."""
target = tmp_path / REL
target.parent.mkdir(parents=True)
target.write_text(
json.dumps({"stations": [{"chainage_m": 760.0}, {"chainage_m": 769.28}]}),
encoding="utf-8",
)
monkeypatch.setattr(sections, "_load_route_polyline", lambda *_: [])
monkeypatch.setattr(sections, "_load_pipe_points", lambda *_: [])
monkeypatch.setattr(
sections, "resolve_extra_stations", lambda *_: [(769.38, "파형강관 D800"), (760.0, "옹벽")]
)
assert _missing_marks(tmp_path, "route.csv") == [
{"chainage_m": 769.38, "label": "파형강관 D800"}
]
+111
View File
@@ -0,0 +1,111 @@
"""193-1 B06 횡단 보라 계획선 이동 제약 — 갈래(도로 · 절토 · 성토)마다 축 · 방향.
화면 TS(`_Cross_Design_Pick`)를 흉내 DOM 으로 돌린 값(`helper_193_1_line_axis.cjs`) +
서버 재계산(노견 확장 = 그 쪽만 · 반대측 고정).
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_193_1_line_axis.cjs"
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
node_only = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@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)
@node_only
def test_갈래_판정(out: dict) -> None:
assert out["axis"] == {"left": "x", "road": "y", "right": "x", "structure": "x"}
assert out["cutZone"] == "cut" and out["cut"]["axis"] == "x"
@node_only
def test_성토측_바깥_가로만_중심_못_넘음(out: dict) -> None:
fill = out["fill"]
assert fill["upBlocked"] and fill["downBlocked"] # ▲▼ 가 종단 계획고로 새지 않음
assert fill["afterUpDown"] == {"left": 0, "right": 0}
assert fill["out2"] == {"left": 0.2, "right": 0} # ← ← = 좌 바깥 0.2 · 우 그대로
assert fill["inward"] == {"left": 0, "right": 0} # 안쪽 30 걸음 = 0 에서 멈춤
assert fill["title"] == "사면 이동 (좌)" and fill["rowHidden"] is False
assert fill["profileUp"] is False # 종단도를 눌렀으면 방향키는 종단도 몫
assert out["right"]["values"] == {"left": 0.1, "right": 0.1} # 우 사면 = 우 바깥만
@node_only
def test_구조물_선_반대측_고정_한_벌(out: dict) -> None:
"""성토측 구조물 트림 선 = 그 쪽 노견째(벽은 노견 끝에 섬) · 반대측 0 그대로."""
assert out["structure"]["values"] == {"left": 0.1, "right": 0}
assert out["structure"]["title"] == "사면 이동 (좌)" and out["structure"]["upBlocked"]
@node_only
def test_절토측_같은_규칙(out: dict) -> None:
cut = out["cut"]
assert cut["upBlocked"]
assert cut["outward"] == {"left": 0.1, "right": 0}
assert cut["inward"] == {"left": 0, "right": 0}
@node_only
def test_도로측_높이만_종단_함께(out: dict) -> None:
road = out["road"]
assert road["upPassed"] and road["downPassed"] # ▲▼ = 종단도 계획고(지금 길 그대로)
assert road["leftBlocked"] and road["rightBlocked"] and road["same"]
assert road["title"] == "노면 높이" and road["rowHidden"] is True
assert "이동" not in road["label"]
assert out["released"] == [False, False] # 고름 풀면 막지 않음
def test_종단도는_막힌_방향키를_건너뜀() -> None:
src = (ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel_Tools.ts").read_text(
encoding="utf-8"
)
assert "if (event.defaultPrevented) return;" in src
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in src
def _ground(level: float) -> list[dict]:
return [{"offset_m": o / 2, "elevation_m": level, "valid": True} for o in range(-30, 31)]
def test_서버_한쪽_이동_반대측_고정_바깥으로() -> None:
samples = _ground(97.0)
base = compute_cross_design(samples, 100.0, ground_type="soil", section_mode="both_fill")
moved = compute_cross_design(
samples, 100.0, ground_type="soil", section_mode="both_fill", shoulder_extend_left_m=0.7
)
right = lambda d: [p for p in d["design_line"] if p["offset_m"] < 0] # noqa: E731
assert right(moved) == right(base) # 반대측 고정
assert moved["road_edges"]["right"] == base["road_edges"]["right"]
assert moved["road_edges"]["left"]["offset_m"] == pytest.approx(
base["road_edges"]["left"]["offset_m"] + 0.7
)
toe = lambda d: max(p["offset_m"] for p in d["design_line"] if p["elevation_m"] > 97.0) # noqa: E731
assert toe(moved) > toe(base) # 사면째 바깥으로
def test_구조물은_노견_끝에_섬() -> None:
"""관 기슭막이 자리 = 설계 노견 끝(`road_edges` · 노견 확장 포함) — 사면과 한 벌로 감."""
src = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Geom.ts").read_text(
encoding="utf-8"
)
assert "const edges = section.design?.road_edges;" in src
@@ -47,14 +47,9 @@ def test_측점이_없으면_빈_목록(tmp_path: Path) -> None:
assert _station_chainages(tmp_path) == []
def test_같은_자리_판정은_50cm() -> None:
"""⚠ 스냅 때문이다 — 관 440.241 은 **측점 440.0** 위에 선다(2026-09-09 실측).
측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅하므로(횡단 파일명이
정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드) 최대 어긋남이 0.5m 다.
0.05m 로 보면 **있는 측점을 없다고 세어** 또 만들라고 한다.
"""
assert STATION_MATCH_TOLERANCE_M == 0.5
def test_같은_자리_판정은_측점_스냅_폭() -> None:
"""측점 생성 스냅 폭(0.005m · 같은 횡단 이름)과 같음 — 0.5m 면 0.1m 옮긴 관을 놓침(192-1)."""
assert STATION_MATCH_TOLERANCE_M == 0.005
def test_샘플링_조건이_없으면_None(tmp_path: Path) -> None: