Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-19 08:15:06 +09:00
13 changed files with 169 additions and 37 deletions
+4 -2
View File
@@ -171,10 +171,12 @@ export function createFacilityLane(
to.setStructureTypes([...types]);
to.setStructures(marks());
const signature = JSON.stringify(stations);
// 종류 표를 받기 전 = 측점 계획 없음(빈 목록을 먼저 쓰면 종단도가 측점을 지웠다 다시 셈 · 467) ·
// 같은 측점이면 안 씀 — 측점을 쓰면 종단도가 계산 묶음을 다시 돌림
if (force || painted?.to !== to || painted.stations !== signature)
if (types.length && (force || painted?.to !== to || painted.stations !== signature)) {
to.setIrregularStations(stations);
painted = { to, stations: signature };
painted = { to, stations: signature };
}
to.setMinCoverPipes(pipes as MinCoverPipe[]);
}
+6 -5
View File
@@ -311,9 +311,9 @@ def _follow_moved_stations(
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤
자리(272)는 옮김. 옮긴 관 · 구조물(427 · 요구 255-2) — 짝 못 찾은 옛 측점과 새 측점이 같은
수면 차례대로 짝 · 높이 고친 꼭짓점(`station_offsets`)은 높이째 · 편집째 옮기고, 나머지 새
측점은 자동 계획선 규칙(원지반 지남 · `_seat_born_nodes`)으로 꼭짓점을 세움(되돌리면 처음
값) · 짝 없는 옛 측점 꼭짓점은 지움.
수면 차례대로 짝 · 높이 고친 꼭짓점(`station_offsets`)은 높이째 · 편집째 옮기고, 나머지 짝
새 측점은 자동 계획선 규칙(원지반 지남 · `_seat_born_nodes`)으로 꼭짓점을 세움(되돌리면 처음
값) · 짝 없는 새 측점은 꼭짓점 안 세움(467) · 짝 없는 옛 측점 꼭짓점은 지움.
측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을,
아니면 `None`.
"""
@@ -351,8 +351,9 @@ def _follow_moved_stations(
)
lost = sorted((key for key, target in moves.items() if target is None), key=float)
offsets = (stored.get("edits") or {}).get("station_offsets") or {}
# 꼭짓점을 새로 세울 측점 — 짝이면 높이 안 고친 것만(편집 R 은 옮김) · 짝 없으면 새 측점 전부
seat = born
# 꼭짓점을 새로 세울 측점 — 짝(옮긴 관 · 구조물)이면 높이 안 고친 것만(편집 R 은 옮김) · 짝 없는 새 측점은
# 안 세움(계획선 그대로 = 브라우저가 보인 계획고 · [저장]으로 값이 안 바뀜 · 467 · 요구 272)
seat: list[float] = []
if lost and len(lost) == len(born):
seat = []
for old_key, value in zip(lost, born):
+30 -5
View File
@@ -9,8 +9,23 @@
import { readState } from "../A00_Common/b_page_state";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
/** 정본 측점 설계 — 받을 때 뜬 사본(다시 셈 · 초안 얹기가 고치는 캐시 객체와 따로). */
export type CanonRow = { chainage_m: number; design: CrossSection["design"] };
/** 정본 측점 설계 — 받을 때 뜬 사본(다시 셈 · 초안 얹기가 고치는 캐시 객체와 따로) · 자리 · 표본(제자리로 돌아온
* 측점이 정본 표본을 다시 씀 · 467 · 배열은 받은 것 그대로 · 다시 셈은 새 배열로 갈아 끼움). */
export type CanonRow = { chainage_m: number; design: CrossSection["design"] } & Partial<
Pick<
CrossSection,
| "center_x"
| "center_y"
| "center_z"
| "frame"
| "samples"
| "azimuth_deg"
| "plan_radius_m"
| "curve_outer_side"
| "curve_widening_m"
| "uphill_side"
>
>;
const canon = new Map<string, CanonRow[]>();
/** 묶음이 먼저 푼 세션 상세 — 상세 가게가 처음 읽을 때 가져감(다시 안 풂). */
@@ -23,9 +38,19 @@ export function keepCanon(projectId: string, routeId: number, detail: SectionDet
parsed.delete(key);
canon.set(
key,
detail.cross_sections.map(({ chainage_m, design }) => ({
chainage_m,
design: design ? { ...design } : design,
detail.cross_sections.map((section) => ({
chainage_m: section.chainage_m,
design: section.design ? { ...section.design } : section.design,
center_x: section.center_x,
center_y: section.center_y,
center_z: section.center_z,
frame: section.frame,
samples: section.samples,
azimuth_deg: section.azimuth_deg,
plan_radius_m: section.plan_radius_m,
curve_outer_side: section.curve_outer_side,
curve_widening_m: section.curve_widening_m,
uphill_side: section.uphill_side,
})),
);
}
+1
View File
@@ -244,6 +244,7 @@ export async function swapSectionStations(
oldKeys,
interval,
useDesignStore(projectId, null).get("routeDraft") !== null,
savedDesigns(projectId, routeId) ?? [],
); // 초안 없으면 정본 표본(467)
// 설계는 사본 — 면적 얹기 · 다시 셈이 앞 상세(초안 전 상세 등)의 설계를 제자리로 안 고침(349-0o)
for (const section of swap.detail.cross_sections)
+30 -9
View File
@@ -7,7 +7,7 @@
* · 지반 표본 · 상단측 · 중심고 = 자리 · 축이 같은 앞 횡단이면 앞 값(서버 샘플링 정본 · 덧 측점만 넣어도 나머지
* 측점 면적이 브라우저 지형으로 안 바뀜 · 433-11) · 새 측점 · 옮긴 측점만 지반 표본(채워 왔으면).
* · 노선 초안 없는 차례(관 · 구조물 · 추가 측점만) = 같은 자리(누가거리 · 좌표 0.01 m · 축 1e-3) 앞 횡단 — 없으면
* 그 자리 옛 횡단(격자에 구조물을 놓고 뺌) — 의 기하 · 표본 그대로(브라우저 측점 틀 ↔ 서버 틀 mm 차가 정본 표본을
* 그 자리 옛 횡단(격자에 구조물을 놓고 뺌) · 그다음 저장 정본(`saved` · 옮겼다 제자리로 온 관) — 의 기하 · 표본 그대로(브라우저 측점 틀 ↔ 서버 틀 mm 차가 정본 표본을
* 안 덮음 · 467 · 요구 272) · 설계는 지금 규칙.
* · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움).
* · 상단측 = 새 지반 표본으로 다시 셈한 측점은 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428).
@@ -109,9 +109,10 @@ function kindOf(station: PipelineStation): SectionStation["kind"] {
return station.kind === "grid" ? "regular" : "irregular";
}
/** 같은 목록인가 — 열쇠 · 누가거리가 차례대로 같음(제자리 갱신이면 갈아끼우지 않음). */
/** 같은 목록인가 — 열쇠 · 누가거리가 차례대로 같음(제자리 갱신이면 갈아끼우지 않음) · 시설 측점 자리가 일반
* 측점으로 돌아감(관 · 구조물 지움 · 467)은 다른 목록. */
export function sameStationList(
sections: ReadonlyArray<{ chainage_m: number }>,
sections: ReadonlyArray<{ chainage_m: number; kind?: SectionStation["kind"] }>,
oldKeys: ReadonlyArray<string>,
stations: ReadonlyArray<PipelineStation>,
): boolean {
@@ -120,7 +121,8 @@ export function sameStationList(
stations.every(
(station, index) =>
Math.abs(station.chainageM - sections[index].chainage_m) < 1e-6 &&
(oldKeys[index] === station.key || kindOf(station) === "regular"),
(oldKeys[index] === station.key ||
(kindOf(station) === "regular" && sections[index].kind !== "irregular")),
)
);
}
@@ -149,8 +151,24 @@ export interface StationSwap {
moved: boolean;
}
/** 정본 자리 한 벌(저장 상세 측점 · 467) — 제자리로 돌아온 측점이 정본 기하 · 표본을 다시 씀. */
export type CanonPlace = Pick<
CrossSection,
| "chainage_m"
| "center_x"
| "center_y"
| "center_z"
| "frame"
| "samples"
| "azimuth_deg"
| "plan_radius_m"
| "curve_outer_side"
| "curve_widening_m"
| "uphill_side"
>;
/** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터 · 허용 `axisTol`)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */
function samePlace(prior: CrossSection, station: PipelineStation, axisTol = 1e-4): boolean {
function samePlace(prior: CanonPlace, station: PipelineStation, axisTol = 1e-4): boolean {
const [lx, ly] = prior.frame?.left_xy ?? [Number.NaN, Number.NaN];
return (
Math.abs(prior.chainage_m - station.chainageM) < 0.01 &&
@@ -174,6 +192,7 @@ export function swapCrossSections(
oldKeys: ReadonlyArray<string>,
intervalM: number | null,
routeDraft = false,
saved: ReadonlyArray<Partial<CanonPlace>> = [],
): StationSwap {
const byKey = new Map(detail.cross_sections.map((section, i) => [oldKeys[i], section]));
const used = new Set<CrossSection>();
@@ -218,13 +237,15 @@ export function swapCrossSections(
moves.set(text(prior.chainage_m), text(station.chainageM));
// 노선 초안 없는 차례 · 같은 자리 = 앞 횡단(없으면 그 자리 옛 횡단 · 격자에 구조물을 놓고 뺌) 기하 · 표본 그대로
// (467 · 되돌리면 정본과 같음)
const atPlace = (section: CrossSection | undefined): section is CrossSection =>
!!section?.samples?.length && samePlace(section, station, CANON_AXIS_TOL);
const canon = routeDraft
const atPlace = (section: Partial<CanonPlace> | undefined): section is CanonPlace =>
!!section?.samples?.length &&
!!section.frame &&
samePlace(section as CanonPlace, station, CANON_AXIS_TOL);
const canon: CanonPlace | undefined = routeDraft
? undefined
: atPlace(prior)
? prior
: detail.cross_sections.find(atPlace);
: (detail.cross_sections.find(atPlace) ?? saved.find(atPlace));
// 자리 같은 앞 횡단 = 서버 표본 그대로(433-11) · 아니면 새 지반 표본(채워 왔으면)
const fresh =
!canon && !!station.ground && !(prior?.samples?.length && samePlace(prior, station));
+6 -4
View File
@@ -347,8 +347,9 @@ export function oneStationList<S extends ListedStation, P extends { chainage_m:
* 사라진 구조물 측점(`gone`)의 계획선 꼭짓점을 새 측점(`born`)으로 옮기거나 지우고, 새로 세울 측점을 고른다 —
* 서버 `_follow_moved_stations`(B05_Profile_Router_Confirm.py) 짝: 0.006m 안 반올림 차이 · 옛 소수 측점의
* 정수 m 맞춤 자리(272)면 높이 그대로 옮김. 옮긴 관 · 구조물(427) — 짝 못 찾은 옛 측점과 새 측점이 같은
* 수면 차례대로 짝 · 높이 고친 꼭짓점(`edited` = 저장 `station_offsets` 키)은 높이째 · 나머지 새 측점은
* `seat`(세움은 부르는 쪽이 `seatBornNodes` · B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 짝 없는 옛
* 수면 차례대로 짝 · 높이 고친 꼭짓점(`edited` = 저장 `station_offsets` 키)은 높이째 · 나머지 짝 새 측점은
* `seat`(세움은 부르는 쪽이 `seatBornNodes` · B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 짝 없는 새 측점은
* 안 세움(467) · 짝 없는 옛
* 꼭짓점은 지움 · `held` = 옮긴 뒤 높이 고친 꼭짓점 측점(서버 옮긴 `station_offsets` 키 · 433-5).
* 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
*/
@@ -370,8 +371,9 @@ export function followMovedNodes<N extends { chainage_m: number }>(
.filter((value) => taken.every((key) => Math.abs(key - value) >= EPS))
.sort((a, b) => a - b);
const lost = gone.filter((old) => moves.get(old) === null).sort((a, b) => a - b);
// 꼭짓점을 새로 세울 측점 — 짝이면 높이 안 고친 것만 · 짝 없으면 새 측점 전부
let seat = left;
// 꼭짓점을 새로 세울 측점 — 짝(옮긴 관 · 구조물)이면 높이 안 고친 것만 · 짝 없는 새 측점은 안 세움(계획선
// 그대로 · [저장]으로 값이 안 바뀜 · 467)
let seat: number[] = [];
if (lost.length && lost.length === left.length) {
seat = [];
lost.forEach((old, index) => {
@@ -74,6 +74,27 @@ out.stored = job.storedCases.map((spec) => m.storedBermSpec(spec));
out.lane = steps;
}
/* 2-2. 시설 줄 칠함 — 종류 표 받기 전 측점(빈 목록)을 종단도에 안 씀 · 받은 뒤 한 번 */
{
const P2 = "p272paint";
const wrote = [];
const fake = {
setStructureTypes: () => undefined,
setStructures: () => undefined,
setIrregularStations: (list) => wrote.push(list.length),
setMinCoverPipes: () => undefined,
resetStationEdit: () => undefined,
};
const lane = m.createFacilityLane(P2, () => fake);
lane.set({ pipes: [] });
lane.set({ structures: [] }, false);
lane.paint();
const before = wrote.length;
lane.setTypes([{ type_id: "berm", name: "소단", placement: "interval" }]);
out.paint = { before, after: wrote.length };
lane.dispose();
}
/* 3. 다시 셈 소단 원천 — 사본 없음(저장분 design.berm) · 사본 = 같은 구간 · 사본 = 빈 목록 */
{
m.writeState("std-cross", job.standard, P);
@@ -183,6 +183,35 @@ const out = {};
};
}
/* 1-3. 관을 옮겼다 제자리로 — 저장 정본(saved)이 있으면 관 측점도 정본 표본 · 기하 */
{
const moved = stationsAt(55);
const keep = m.swapCrossSections(canonDetail(), moved, KEYS, 20, false);
const saved = canonDetail().cross_sections;
const back = m.swapCrossSections(keep.detail, stationsAt(45), keysOf(moved), 20, false, saved);
out.pipeBack = { pipe: report(back.detail)["45.00"], bytes: bytesSame(back.detail, [45]) };
}
/* 1-4. 일반 측점 자리(40)에 선 관을 지움 — 자리 · 수 같아도 다른 목록(관 카드가 안 남음) */
{
const onGrid = stationsAt(45).map((s) =>
s.key === "grid:2" ? { ...s, key: "pipe:p9", kind: "pipe", id: "p9" } : s,
);
const placed = m.swapCrossSections(canonDetail(), onGrid, KEYS, 20, false).detail;
placed.cross_sections.find((s) => s.chainage_m === 40).culvert = { pipe: "p9" };
const gone = stationsAt(45);
out.pipeGone = {
placedKind: placed.cross_sections.find((s) => s.chainage_m === 40).kind,
same: m.sameStationList(placed.cross_sections, keysOf(onGrid), gone),
sameUntouched: m.sameStationList(canonDetail().cross_sections, KEYS, gone),
};
const after = m.swapCrossSections(placed, gone, keysOf(onGrid), 20, false).detail;
const at40 = after.cross_sections.find((s) => s.chainage_m === 40);
out.pipeGone.culvertLeft = "culvert" in at40;
out.pipeGone.kind = at40.kind;
out.pipeGone.canon = report(after)["40.00"].same;
}
/* 2. 가게 길(swapSectionStations) — 가게 routeDraft 로 가름: 없음 · 있음 · 초안 버린 직후 */
const seed = (P, R) => {
m.writeState("section-detail", canonDetail(), P, R);
@@ -1,5 +1,8 @@
/* 467 시험 묶음 진입점 — 갈아끼움 정본 표본 유지(진짜 모듈 · vite ssr). */
export { swapCrossSections } from "../../B06_Section/logic/B06_Section_Station_Swap";
export {
sameStationList,
swapCrossSections,
} from "../../B06_Section/logic/B06_Section_Station_Swap";
export {
loadSectionDetail,
swapSectionStations,
+5
View File
@@ -198,3 +198,8 @@ def test_쓰는_자리_하나():
assert "writeBermSpans" not in sync and "useStructureIds(projectId, structures)" in sync
lane = (ROOT / "A00_Common" / "design" / "design_facility_lane.ts").read_text(encoding="utf-8")
assert "writeBermSpans(projectId, routeId, next)" in lane
def test_시설_줄_종류_표_전_측점_안_칠함(out):
assert out["paint"]["before"] == 0 # 종류 표 전 = 빈 측점을 종단도에 안 씀
assert out["paint"]["after"] == 1 # 받은 뒤 한 번
+20 -3
View File
@@ -84,7 +84,9 @@ def test_되돌림_정본과_바이트_같음(out):
def test_격자에_구조물_놓고_뺌_같은_자리_정본(out):
g = out["gridStructure"]
assert g["placed"]["same"] and not g["placed"]["browser"] # 열쇠 바뀜(구조물) · 같은 자리 = 정본
assert (
g["placed"]["same"] and not g["placed"]["browser"]
) # 열쇠 바뀜(구조물) · 같은 자리 = 정본
assert g["removed"]["same"] and not g["removed"]["browser"] # 다시 격자 = 정본 표본 · 기하
assert g["removedBytes"]
@@ -112,7 +114,22 @@ def test_가게_길_초안_있음_버린_직후(out):
for at in STRAIGHT: # 초안 차례 = 옛 규칙(기하 새 틀 · 표본 앞 값)
assert not s["draft"][at]["same"] and not s["draft"][at]["browser"], at
dropped = s["dropped"]
for at in CURVES: # 버린 뒤 = 저장 노선 자리 표본(초안 3 cm 자리 표본이 안 남음)
assert dropped[at]["browser"], at
for at in CURVES: # 버린 뒤 = 저장 정본 자리 · 표본(초안 3 cm 자리 표본이 안 남음 · 467 덧 정본 물러섬)
assert dropped[at]["same"] and not dropped[at]["browser"], at
for at in STRAIGHT: # 초안이 안 옮긴 측점 = 앞 횡단 표본(서버) 그대로
assert not dropped[at]["browser"], at
def test_옮겼다_제자리_관_측점_정본(out):
p = out["pipeBack"]
assert p["pipe"]["same"] and not p["pipe"]["browser"] # 저장 정본 표본 · 기하
assert p["bytes"] # 관 밖 횡단 = 정본 바이트(관 설계 = 옮김 규칙 · 다시 셈 입력은 정본 물러섬)
def test_일반_측점_자리_관_지움_다른_목록(out):
g = out["pipeGone"]
assert g["placedKind"] == "irregular"
assert g["same"] is False # 관 카드 → 일반 측점 = 갈아끼움
assert g["sameUntouched"] is True # 일반 측점 그대로 = 같은 목록
assert g["culvertLeft"] is False and g["kind"] == "regular" # 관 카드 안 남음
assert g["canon"] # 그 자리 정본 표본 · 기하
+3 -4
View File
@@ -71,10 +71,9 @@ def test_저장_기준_입력이_심는_측점(planted):
"both"
] # 꺼진 종류도
assert planted["noDetail"] is True # 상세 없음 = 종단도 기준
# 저장 꼭짓점(양 끝) + 심은 측점마다 세운 꼭짓점(427 · seatBornNodes)
born = [c for k, c in planted["both"] if k == "irregular" and 0 < c < 60]
assert planted["pviAt"] == sorted([0, 60, *born])
assert planted["pvi"] == 2 + len(born)
# 저장 꼭짓점(양 끝)만 — 짝 없는 새(심은) 측점엔 꼭짓점 안 세움(계획선 그대로 · 467 · 요구 272)
assert planted["pviAt"] == [0, 60]
assert planted["pvi"] == 2
def test_늦게_심어도_안_돎(r):
+10 -4
View File
@@ -35,6 +35,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
rebuild_alignment_profile,
)
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
from common_util.common_util_route_profile import design_elevation_from_longitudinal # noqa: E402
REL = "B06_Section/longitudinal/longitudinal.json"
PIPE = 351.0
@@ -147,15 +148,20 @@ def test_높이_고친_꼭짓점은_높이째_옮김(tmp_path: Path) -> None:
assert after[at] == pytest.approx(value, abs=1e-6), at
def test_짝_없는_새_측점도_세우고_옛_꼭짓점은_지움(tmp_path: Path) -> None:
"""관 하나 지우고 둘 놓음(수가 다름) — 새 측점 둘 다 원지반 지남 · 옛 편집 지움."""
_write(tmp_path, _longitudinal({"351.000": 0.4}))
def test_짝_없는_새_측점은_꼭짓점_안_세우고_옛_꼭짓점은_지움(tmp_path: Path) -> None:
"""관 하나 지우고 둘 놓음(수가 다름) — 새 측점 둘 = 꼭짓점 안 세움 · 계획고 = 놓기 전 계획선 높이
(브라우저가 보인 값 · [저장]으로 안 바뀜 · 467 · 요구 272) · 옛 편집 지움."""
before = _longitudinal({"351.000": 0.4})
_write(tmp_path, before)
after = _move(tmp_path, 125.0, 287.0)
rows = _rows(after, 0, 400)
assert PIPE not in rows
assert after["profile_alignment"]["edits"]["station_offsets"] == {}
nodes = [float(n["chainage_m"]) for n in after["profile_alignment"]["base_pvi"]]
for at in (125.0, 287.0):
assert rows[at] == pytest.approx(_ground(at), abs=1e-4)
assert all(abs(at - s) > 0.006 for s in nodes) # 꼭짓점 없음
assert rows[at] == pytest.approx(design_elevation_from_longitudinal(before, at), abs=1e-3)
assert abs(rows[at] - _ground(at)) > 1e-3 # 원지반으로 안 내려앉음
def _ts(tmp_path: Path, cases: list[dict]) -> list[list[dict]]: