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

This commit is contained in:
2026-10-18 23:18:02 +09:00
40 changed files with 2011 additions and 200 deletions
@@ -8,7 +8,9 @@
* `resources/tester/test_346_2_pipeline_cross_page.py`.
* crossWithUphill cross 단계 입력에 단면 유형 기본값(상단측)을 얹음 — 서버 노선 저장이 단면 유형 ·
* 측구 방향을 새 지형으로 정하는 것과 같은 규칙(넘기는 칸 = 부르는 쪽 입력).
* draftSectionDetail ground · profile · cross → 상세(측점 · 지반 · 횡단 설계 · 얹힌 시설 제원).
* crossWithShapes 얹힌 시설 맥락(포장 · 기울임 · 하강) = 지금 현황을 새 측점에 다시 얹은 그림에서(457).
* draftSectionDetail ground · profile · cross → 상세(측점 · 지반 · 횡단 설계 · 그림 자료 · 카드 이름 = 지금
* 현황을 새 측점에 다시 얹음 · 캐시 `withDraftShapes` 와 같은 `attachShapes` · 457).
*
* 순수 — 화면 · fetch · 세션 없음.
* ========================================================================== */
@@ -26,24 +28,26 @@ import type {
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { toDesignProfile } from "./B05_Profile_UI_Profile_Data";
import { stationLabel } from "../../common_util/common_util_station";
import { attachShapes, type AttachType } from "../../common_util/common_util_structure_attach";
import { registryDefaults } from "../../common_util/common_util_culvert_sets";
import {
planStations,
SAME_STATION_M,
type PlanPipe,
type PlanStructure,
type PlanType,
} from "../../common_util/common_util_structure_station";
import { stationIdOf } from "./B05_Profile_Pipeline_Grade";
/** 측점에 얹힌 시설 제원(저장 상세에서 측점 열쇠로 옮김) — 낡음 판정 · 그림이 읽음 · 카드 번호(`station_id` ·
* 있으면 저장 카드 번호를 이어 씀 · 440). */
export type SectionSpecs = Partial<
Pick<
CrossSection,
| "station_id"
| "structure"
| "culvert"
| "ford"
| "box"
| "ford_pavement"
| "revetment"
| "pavement"
| "underdrain"
>
>;
/** 측점 열쇠로 이어 받는 카드 번호(`station_id` · 저장 카드 번호를 이어 씀 · 440) — 그림 자료 · 이름은 안 옮김(457). */
export type SectionSpecs = Partial<Pick<CrossSection, "station_id">>;
/** 지금 현황(follow 뒤 관 · 구조물) + 구조물 표 — 새 측점에 그림 · 이름 · 시설 맥락을 다시 얹는 재료(457). */
export interface ShapeStatus {
pipes: ReadonlyArray<PlanPipe>;
structures: ReadonlyArray<PlanStructure>;
types: ReadonlyArray<AttachType>;
}
/** cross 단계 입력 꼴(`B06_Section_Cross_Stage` `CrossStageInput`) — 칸은 그 단계 몫. */
export interface CrossInput {
@@ -128,19 +132,6 @@ const kindOf = (station: PipelineStation): SectionStation["kind"] =>
? "curve"
: "irregular";
/** 제원의 누가거리 = 새 측점(서버 부착은 따라간 관 · 구조물 자리 = 그 측점) — 옛 값이면 구조물 면적이 관 자리를 잘못 잡음(349-0n). */
function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs {
const out: Record<string, unknown> = { ...own };
for (const [name, spec] of Object.entries(own))
if (
spec &&
typeof spec === "object" &&
typeof (spec as { chainage_m?: unknown }).chainage_m === "number"
)
out[name] = { ...spec, chainage_m: chainageM };
return out as SectionSpecs;
}
/** 저장 상세 → 묶음 cross 출력(365-4 · 저장 노선 = 다시 셈 창구 한 벌이 고친 설계 그대로) — 측점이 상세와
* 다르면(갈아끼움 전) null. */
export function detailCross(
@@ -181,7 +172,6 @@ export function stationSection(
chainage_m: station.chainageM,
label: stationLabel(station.chainageM, intervalM),
kind: kindOf(station),
...(own.structure ? { structure: own.structure } : {}),
center_z: center,
azimuth_deg: station.azimuthDeg,
plan_radius_m: station.planRadiusM,
@@ -215,8 +205,82 @@ export function stationSection(
};
}
/** 측점마다 그림 자료 · 카드 이름 = 지금 현황을 그 자리에 다시 얹음(캐시 `withDraftShapes` · 이름 = 측점 계획
* 제목 · 457) — 옛 측점 그림을 옮기지 않음. */
function attachStatus(sections: CrossSection[], status: ShapeStatus): void {
const { pipes, structures, types } = status;
attachShapes(sections as unknown as Array<Record<string, unknown>>, pipes, structures, types);
const planned = planStations(pipes, structures, types, registryDefaults(types));
for (const section of sections) {
const hit = planned.find((p) => Math.abs(p.chainage_m - section.chainage_m) < SAME_STATION_M);
if (hit) section.structure = hit.title;
else delete section.structure;
}
}
/** 현황 묶음(입력 칸은 묶음 모양 `unknown` · 457) — 정의 옵션 · 이름이 빈 종류는 빈 목록 · 종류 id. */
export const shapeStatus = (
pipes: unknown,
structures: unknown,
types: ReadonlyArray<PlanType & { name?: string }>,
): ShapeStatus =>
({
pipes,
structures,
types: types.map((type) => ({
...type,
name: type.name ?? type.type_id,
options: type.options ?? [],
})),
}) as ShapeStatus;
/** 그 측점 그림 자료 → 얹힌 시설 맥락(B06 `Cross_Refresh` 와 같은 규칙 · 서버 `tilt_ranges` · `ford_drop_at` 짝). */
function shapeContext(section: CrossSection): Record<string, unknown> {
const drop = Number(section.ford?.overflow_depth_m);
return {
paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement),
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
surfaceDropM: drop > 0 ? drop : 0,
};
}
/** 측점마다 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강) = 지금 현황을 새 측점에 다시 얹은 그림에서(457). */
export function crossWithShapes(
input: CrossInput | null,
stations: ReadonlyArray<PipelineStation>,
status: ShapeStatus | null,
): CrossInput | null {
if (!input || !status) return input;
const sections = stations.map((station) => ({ chainage_m: station.chainageM }) as CrossSection);
attachShapes(
sections as unknown as Array<Record<string, unknown>>,
status.pipes,
status.structures,
status.types,
);
const byKey: CrossInput["byKey"] = { ...input.byKey };
stations.forEach((station, i) => {
byKey[station.key] = { ...byKey[station.key], ...shapeContext(sections[i]) };
});
return { ...input, byKey };
}
/** cross 단계 입력 = 시설 맥락(새 자리 그림) · 단면 유형 기본값(상단측) · 소단(지금 구조물) 얹음 — 페이지 묶음 cross. */
export const crossWithContext = (
input: CrossInput | null,
stations: ReadonlyArray<PipelineStation>,
status: ShapeStatus | null,
bermAt: BermAt | null,
): CrossInput | null =>
crossWithBerm(
crossWithUphill(crossWithShapes(input, stations, status), stations),
stations,
bermAt,
);
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름)는 그대로 · 측점 · 지반 · 횡단 · 계획선 표본은 새 노선
* (십자선 대체 높이도 초안 종단 · 413). */
* (십자선 대체 높이도 초안 종단 · 413) · 그림 자료 · 카드 이름 = `status`(지금 현황)를 새 측점에 다시 얹음(457). */
export function draftSectionDetail(
saved: SectionDetailResponse,
ground: GroundOutput,
@@ -224,15 +288,12 @@ export function draftSectionDetail(
cross: CrossOutput | null,
specs: ReadonlyMap<string, SectionSpecs>,
intervalM: number,
status: ShapeStatus | null = null,
): SectionDetailResponse {
const sections: CrossSection[] = ground.stations.map((station) =>
stationSection(
station,
cross,
atChainage(specs.get(station.key) ?? {}, station.chainageM),
intervalM,
),
stationSection(station, cross, specs.get(station.key) ?? {}, intervalM),
);
if (status) attachStatus(sections, status);
const { chainageM, z } = ground.longitudinal;
return {
longitudinal: {
@@ -18,6 +18,8 @@ import type { AlignmentCurve, AlignmentPolicy } from "./B05_Profile_UI_Profile_A
export const DEFAULT_CURVE_LENGTH_M = 20;
/** 초기값 직선 문턱 R(m) — config `initial_straight_radius_m` 짝. */
export const INITIAL_STRAIGHT_RADIUS_M = 500;
/** 법정 미달 판정 오차(m) — R = L/Δ 를 반올림 높이로 되셈한 L 19.99998 거짓 미달 막음(271-1) · 파이썬 짝. */
export const SHORTFALL_TOLERANCE_M = 1e-3;
/** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것 — 파이썬 `flat_bends` · 시종점 false. */
export function flatBends(
@@ -72,7 +74,10 @@ export function verticalCurveShortfalls(
if (minR <= 0 && minL <= 0) return [];
return curves
.filter((curve) => !curve.omitted && !curve.skip_allowed)
.filter((curve) => curve.r_m < minR - 1e-6 || curve.l_m < minL - 1e-6)
.filter(
(curve) =>
curve.r_m < minR - SHORTFALL_TOLERANCE_M || curve.l_m < minL - SHORTFALL_TOLERANCE_M,
)
.map(
(curve) =>
`${curve.chainage_m.toFixed(1)}m: 종단곡선 R ${curve.r_m.toFixed(0)}m · L ${curve.l_m.toFixed(1)}m — ` +
@@ -109,6 +109,24 @@ export function hasStraightRuns(alignment: ProfileAlignment): boolean {
return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index));
}
/**
* 고른 범위가 이미 한 직선이면 그 범위를 부분 구간으로 돌려준다(271-1) — [직선화] ▲▼ 로 꺾으면
* 양 끝에 기본 L 곡선. 사이 꼭짓점이 없거나 하나라도 꺾였으면 null(보통 직선화).
*/
export function straightSubRun(
alignment: ProfileAlignment,
fromChainageM: number,
toChainageM: number,
): StraightRun | null {
const fromM = Math.min(fromChainageM, toChainageM);
const toM = Math.max(fromChainageM, toChainageM);
const inside = alignment.pvi
.map((node, index) => ({ at: node.chainage_m, index }))
.filter(({ at }) => at > fromM + SAME_STATION_M && at < toM - SAME_STATION_M);
if (!inside.length || !inside.every(({ index }) => straightAt(alignment.pvi, index))) return null;
return { fromM, toM, nodes: [fromM, ...inside.map(({ at }) => at), toM] };
}
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] {
return pviChainages(alignment).filter(
@@ -131,17 +149,33 @@ function withOffsets(
return { station_offsets: stationOffsets, curve_radii: curveRadii };
}
/**
* 범위 끝 R 이 저장 자동 R 과 같으면 지운다 — 끝 곡선 = 기본 L(271-1 · ▲▼ 433-13 과 같은 규칙) ·
* 사용자 R 은 둠. `radii` 를 고쳐 쓴다.
*/
export function dropAutoEndRadii(
radii: Record<string, number>,
ends: number[],
autoRadii: Readonly<Record<string, number>>,
): void {
ends.map(chainageKey).forEach((key) => {
if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key];
});
}
/**
* 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다.
*
* 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의
* 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다).
* 끝 R 이 저장 자동 R(`autoRadii`)이면 지워 기본 L(271-1).
*/
export function straightenBetween(
base: AlignmentBase,
edits: AlignmentEdits,
fromChainageM: number,
toChainageM: number,
autoRadii: Readonly<Record<string, number>> = {},
): AlignmentEdits {
const fromM = Math.min(fromChainageM, toChainageM);
const toM = Math.max(fromChainageM, toChainageM);
@@ -156,11 +190,13 @@ export function straightenBetween(
const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span;
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
});
return withOffsets(
const next = withOffsets(
edits,
[...updates, ...pinEnds(base, current, [fromM, toM])],
inner.map(chainageKey),
);
dropAutoEndRadii(next.curve_radii, [fromM, toM], autoRadii);
return next;
}
/**
@@ -245,6 +281,8 @@ export function tiltStraightRun(
if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ);
else place(chainage, pivot, pivotZ, run.toM, endZ);
});
// 부분 구간(271-1) 끝이 꼭짓점 아닌 측점이면 지금 높이로 세움 — 끝이 따라 움직이지 않게
updates.push(...pinEnds(base, current, [run.fromM, run.toM]));
const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12);
const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12);