Merge remote-tracking branch 'origin/dev' into sub_laptop_7

This commit is contained in:
2026-10-19 07:36:43 +09:00
21 changed files with 1478 additions and 111 deletions
+32 -5
View File
@@ -34,8 +34,15 @@ import {
readCutSlopeSession,
readRockBoundarySession,
} from "./B06_Section_UI_Page_Persist";
import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist";
import type { BermSpec } from "@util/common_util_cross_berm";
import type { BermSessionSpec, BermSpan } from "./B06_Section_UI_Page_Persist";
import {
BERM_DEFAULT_DEPTH_M,
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
type BermSpec,
} from "@util/common_util_cross_berm";
import { readState } from "../A00_Common/b_page_state";
import {
crossDesignChoices,
readBermDepths,
@@ -260,6 +267,24 @@ function bermPayload(
type Side = "left" | "right";
const SIDES: Side[] = ["left", "right"];
/** 저장분 소단 제원(`design.berm`) — 짝: 파이썬 `berm_from`(빈 칸 = 기본값 · 깊이 0 미만 = 0 · 못 읽으면 없음). */
export function storedBermSpec(berm: unknown): BermSessionSpec | null {
if (!berm || typeof berm !== "object" || Array.isArray(berm)) return null;
const spec = berm as Record<string, unknown>;
const value = (key: string, fallback: number): number => {
const raw = spec[key];
if (raw === undefined) return fallback;
return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw);
};
const out = {
width_m: value("width_m", BERM_DEFAULT_WIDTH_M),
interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M),
slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG),
depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0),
};
return Object.values(out).every(Number.isFinite) ? out : null;
}
/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */
function bermDepthReader(projectId: string, routeId: number) {
const session = readBermDepths(projectId, routeId);
@@ -333,11 +358,13 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
// 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07).
const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId);
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
// 순간 계단이 사라진다(계획서 3-9).
const bermSpans = readBermSpans(projectId, input.routeId);
// 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`
// (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467).
const bermCopy = readState<BermSpan[]>("berm", projectId, input.routeId);
const bermSpans = Array.isArray(bermCopy) ? bermCopy : null;
const bermDepthAt = bermDepthReader(projectId, input.routeId);
const bermAt = (chainageM: number, design: Record<string, unknown>): BermSpec | null => {
const spec = bermSpecAt(bermSpans, chainageM);
const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm);
return spec
? {
widthM: spec.width_m,
+8 -1
View File
@@ -41,6 +41,7 @@ import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Re
import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options";
import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types";
import type { DesignStoreValues } from "../A00_Common/design/design_store";
import { useDesignStore } from "../A00_Common/design/design_store";
import {
carryStationKeys,
KEYED_STATES,
@@ -237,7 +238,13 @@ export async function swapSectionStations(
const interval = stationIntervalOf(current);
const oldKeys = registeredKeys(projectId, routeId, current.cross_sections, interval);
if (sameStationList(current.cross_sections, oldKeys, stations)) return null;
const swap = swapCrossSections(current, stations, oldKeys, interval);
const swap = swapCrossSections(
current,
stations,
oldKeys,
interval,
useDesignStore(projectId, null).get("routeDraft") !== null,
); // 초안 없으면 정본 표본(467)
// 설계는 사본 — 면적 얹기 · 다시 셈이 앞 상세(초안 전 상세 등)의 설계를 제자리로 안 고침(349-0o)
for (const section of swap.detail.cross_sections)
if (section.design) section.design = { ...section.design };
+4 -19
View File
@@ -5,7 +5,7 @@
* - `handleDesignChange` : 버튼 선택 즉시 반영 + 세션 초안 기록 + 재계산
* - `recomputeIfRock` : 암 경계·암 절토 경사 변경 후 암 측점만 재계산
* - `reconcileStaleDesigns`: 횡단 다시 셈을 계산 묶음에 부탁(cross 단계 한 곳 · 387-4)
* - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기
* - `syncBermSpans` : 구조물 측점 열쇠(소단 사본은 페이지 시설 줄이 씀 · 467)
* - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영]
* 고친 뒤 계산 묶음 cross 입력도 다시(`pipelineCross` · 노선 초안 동안 카드 = 묶음 새 노선 상세 · 365-2).
* ========================================================================== */
@@ -15,11 +15,6 @@ import {
writeCrossDesignChoice,
writeShoulderExtend,
} from "./B06_Section_Cross_Design_Session";
import {
bermSpansFromStructures,
readBermSpans,
writeBermSpans,
} from "./B06_Section_UI_Page_Persist";
import { showToast } from "@ui/ui_template_elements";
import { L } from "./B06_Section_UI_Page_Common";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
@@ -137,20 +132,10 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
await reconcilePagePipeline(projectId);
}
/**
* 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다.
*
* 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만
* 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다.
*/
/** 구조물 측점 열쇠 = 구조물 id(344-3) — 소단 사본(세션 `berm`)은 페이지 시설 줄 한 곳이 씀(B05 만 열어도 · 467). */
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
const currentRouteId = ctx.routeId();
if (!projectId || currentRouteId === null) return;
useStructureIds(projectId, structures); // 구조물 측점 열쇠 = 구조물 id(344-3)
const next = bermSpansFromStructures(structures);
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
writeBermSpans(projectId, currentRouteId, next);
void reconcileStaleDesigns({ force: true });
if (!projectId || ctx.routeId() === null) return;
useStructureIds(projectId, structures);
}
/** 패널 [전체 반영](N-2-1 · 346-2): 표준값으로 전 측점을 **한 번** 다시 셈 — 측점마다 돌면
@@ -35,7 +35,7 @@ export interface SessionMap<V> {
values: Map<string, V>;
/** 세션값 읽기 — 맵을 비우고 다시 채운다. */
load: () => void;
/** 맵을 통째로 세션에 쓴다. */
/** 맵을 통째로 세션에 쓴다 — 빈 맵이면 칸을 지운다. */
persist: () => void;
}
@@ -98,7 +98,8 @@ export function createSessionMap<V>(
const key = sessionKey();
if (!key) return;
try {
writeByKey(key, JSON.stringify(Object.fromEntries(values)));
// 빈 맵 = 칸 지움(272) — 「{}」 를 남기면 사진첩이 걸음으로 봄
writeByKey(key, values.size ? JSON.stringify(Object.fromEntries(values)) : null);
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
@@ -144,10 +145,12 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag
if (!key) return;
writeByKey(
key,
JSON.stringify({
detached: Object.fromEntries(detached),
grade: Object.fromEntries(followGrade),
}),
detached.size || followGrade.size
? JSON.stringify({
detached: Object.fromEntries(detached),
grade: Object.fromEntries(followGrade),
})
: null, // 둘 다 비면 칸 지움(272)
);
},
};
+53 -18
View File
@@ -6,6 +6,9 @@
* 받음 — 설계 · 구조물 제원 · 이름 그대로 · 자리(누가거리 · 좌표 · 방위 · 확폭)만 새것(433-4).
* · 지반 표본 · 상단측 · 중심고 = 자리 · 축이 같은 앞 횡단이면 앞 값(서버 샘플링 정본 · 덧 측점만 넣어도 나머지
* 측점 면적이 브라우저 지형으로 안 바뀜 · 433-11) · 새 측점 · 옮긴 측점만 지반 표본(채워 왔으면).
* · 노선 초안 없는 차례(관 · 구조물 · 추가 측점만) = 같은 자리(누가거리 · 좌표 0.01 m · 축 1e-3) 앞 횡단 — 없으면
* 그 자리 옛 횡단(격자에 구조물을 놓고 뺌) — 의 기하 · 표본 그대로(브라우저 측점 틀 ↔ 서버 틀 mm 차가 정본 표본을
* 안 덮음 · 467 · 요구 272) · 설계는 지금 규칙.
* · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움).
* · 상단측 = 새 지반 표본으로 다시 셈한 측점은 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428).
* · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가
@@ -146,27 +149,31 @@ export interface StationSwap {
moved: boolean;
}
/** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */
function samePlace(prior: CrossSection, station: PipelineStation): boolean {
/** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터 · 허용 `axisTol`)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */
function samePlace(prior: CrossSection, 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 &&
Math.hypot(prior.center_x - station.x, prior.center_y - station.y) <= 0.01 &&
Math.abs(lx - station.left[0]) < 1e-4 &&
Math.abs(ly - station.left[1]) < 1e-4
Math.abs(lx - station.left[0]) < axisTol &&
Math.abs(ly - station.left[1]) < axisTol
);
}
/** 노선 초안 없는 차례의 축 허용(측점 축 0.06° · 20 m 끝 2 cm) — 브라우저 틀 ↔ 서버 틀 차(R 12 곡선 3.3e-4)를 넘김. */
const CANON_AXIS_TOL = 1e-3;
/**
* 새 측점 목록으로 상세를 새로 짬. `oldKeys` = 지금 상세 측점마다 열쇠(같은 차례 · `stationKeysOf`) ·
* 열쇠가 안 맞으면 옮긴 곡선 열쇠(`curveMoves` · 서버 `carry_cross_inputs` 같은 규칙) · 그다음 같은 자리(0.01 m) ·
* 같은 종류의 앞 횡단을 씀.
* 같은 종류의 앞 횡단을 씀. `routeDraft` = 노선 초안 차례(가게 `routeDraft` · 아니면 정본 기하 · 표본 유지 · 467).
*/
export function swapCrossSections(
detail: SectionDetailResponse,
stations: ReadonlyArray<PipelineStation>,
oldKeys: ReadonlyArray<string>,
intervalM: number | null,
routeDraft = false,
): StationSwap {
const byKey = new Map(detail.cross_sections.map((section, i) => [oldKeys[i], section]));
const used = new Set<CrossSection>();
@@ -209,8 +216,18 @@ export function swapCrossSections(
moved = true;
if (prior && text(prior.chainage_m) !== text(station.chainageM))
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
? undefined
: atPlace(prior)
? prior
: detail.cross_sections.find(atPlace);
// 자리 같은 앞 횡단 = 서버 표본 그대로(433-11) · 아니면 새 지반 표본(채워 왔으면)
const fresh = !!station.ground && !(prior?.samples?.length && samePlace(prior, station));
const fresh =
!canon && !!station.ground && !(prior?.samples?.length && samePlace(prior, station));
const kind = kindOf(station);
const onGrid =
kind === "irregular" &&
@@ -230,20 +247,38 @@ export function swapCrossSections(
label: formatStation(station.chainageM),
kind,
...(onGrid ? { grid: true } : {}),
center_z: fresh
? (station.ground?.centerZ ?? prior?.center_z ?? null)
: (prior?.center_z ?? null),
azimuth_deg: station.azimuthDeg,
plan_radius_m: station.planRadiusM,
curve_outer_side: station.curveOuterSide,
curve_widening_m: station.curveWideningM,
center_x: station.x,
center_y: station.y,
frame: { left_xy: [station.left[0], station.left[1]] as [number, number] },
center_z: canon
? canon.center_z
: fresh
? (station.ground?.centerZ ?? prior?.center_z ?? null)
: (prior?.center_z ?? null),
...(canon
? {
azimuth_deg: canon.azimuth_deg,
plan_radius_m: canon.plan_radius_m,
curve_outer_side: canon.curve_outer_side,
curve_widening_m: canon.curve_widening_m,
center_x: canon.center_x,
center_y: canon.center_y,
frame: canon.frame,
}
: {
azimuth_deg: station.azimuthDeg,
plan_radius_m: station.planRadiusM,
curve_outer_side: station.curveOuterSide,
curve_widening_m: station.curveWideningM,
center_x: station.x,
center_y: station.y,
frame: { left_xy: [station.left[0], station.left[1]] as [number, number] },
}),
};
longStations.push({ ...place, ...(prior?.structure ? { structure: prior.structure } : {}) });
const samples = fresh ? groundSamples(station) : (prior?.samples ?? []);
const uphill = fresh ? uphillSide(station) : (prior?.uphill_side ?? null);
const samples = canon ? canon.samples : fresh ? groundSamples(station) : (prior?.samples ?? []);
const uphill = canon
? (canon.uphill_side ?? null)
: fresh
? uphillSide(station)
: (prior?.uphill_side ?? null);
if (!prior) return { ...place, uphill_side: uphill, samples, design: freshDesign(uphill) };
const reseed =
(station.kind === "pipe" || station.kind === "structure") &&