feat(511-B B-3 종단 짝 Node): 종단 다시 셈(rebuild_alignment_profile · 일곱 곳) · 1순위 첫 계획선(design_ground_following_profile) · 계획고 옮김(carry_edits) = 서버 늘 떠 있는 Node 가 같은 TS(profile.base · profile.alignment · profile.carry · design_calc_node_profile.ts) · 파이썬 = 입력 모음 + 정본 꼴(Engine_Grade_Node.alignment_record · 옛 build_alignment 글 그대로) · 노선 [저장] 계획고 옮김 = 편집 옮김 + 꼭짓점 세움 Node 한 부름 · P1 2~4 순위 폴백 파이썬 남김 · TS 표본 격자 0.000001 m(np.round 짝) · Alignment.ts 700 → 630 + _Edit 100(쉬프트 · R) · 기준표 logic(Grade_Criteria · X5) · plantedBase 셈 logic(design_planted_base · Profile_Planted) = Node 입구 끌림 11 걷힘 · 등록부 P1~P5 P8 node: · 정본 글 같음 = 시험 9 + storage 종단 16 파일 첫 계획선 · 다시 셈 16/16 — 요구 320-4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,34 +8,18 @@
|
||||
* 종류 = 구조물 정의 전부(보기와 같음). 화면 모듈을 끌어 묶음 대신 페이지(`design_shared`)가 옵션으로 넘김.
|
||||
* ========================================================================== */
|
||||
|
||||
import { listedBase } from "./design_profile_base";
|
||||
import { plannedIrregular } from "./design_structure_status";
|
||||
import { withPipeIds } from "./design_pipe_ids";
|
||||
import { planStations, type PlanType } from "../../common_util/common_util_structure_station";
|
||||
import type { PipelineInputs } from "./logic/design_pipeline_types";
|
||||
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
toAlignmentBase,
|
||||
type AlignmentBase,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { plantedBase as plantedBaseWith } from "./logic/design_planted_base";
|
||||
import type { PlanType } from "../../common_util/common_util_structure_station";
|
||||
import type { AlignmentBase } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
/** 저장 상세(종단도가 그리는 것) + 지금 입력이 심는 측점 = 저장 노선 종단 기준. 상세가 없으면 종단도 기준. */
|
||||
/** 저장 상세 + 지금 입력이 심는 측점 = 저장 노선 종단 기준(셈 = logic) · 관 id = 세션 장부(`withPipeIds`). */
|
||||
export function plantedBase(
|
||||
source: { base: AlignmentBase | null; detail?: SectionDetailResponse | null },
|
||||
inputs: Pick<PipelineInputs, "pipes" | "structures">,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
/** 관 세션 id — 없으면 세션 장부(`withPipeIds`) · 서버 Node 는 장부 사본(511-B). */
|
||||
pipeId?: (chainageM: number) => string,
|
||||
): AlignmentBase | null {
|
||||
const detail = source.detail ?? null;
|
||||
const stored = detail && readAlignment(detail.longitudinal);
|
||||
if (!detail || !stored) return source.base;
|
||||
const list = (inputs.pipes ?? []) as Array<{ chainage_m: number }>;
|
||||
const pipes = pipeId
|
||||
? list.map((pipe) => ({ ...pipe, id: pipeId(pipe.chainage_m) }))
|
||||
: withPipeIds(list);
|
||||
const structures = (inputs.structures ?? []) as Parameters<typeof planStations>[1];
|
||||
const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관
|
||||
return listedBase(toAlignmentBase(stored), detail, planted);
|
||||
return plantedBaseWith(source, inputs, types, (pipes) => withPipeIds(pipes));
|
||||
}
|
||||
|
||||
@@ -4,50 +4,5 @@
|
||||
* 계산 묶음 planline 단계(`design_pipeline_page`)도 같은 기준을 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { AlignmentBase } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
||||
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
||||
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import { seatBornNodes } from "../../B05_Profile/logic/B05_Profile_Grade_Seat";
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · `seatBornNodes` · 427) ·
|
||||
* 늘 저장분에서 셈. 심은 측점이 아직 없으면(null · 시설 줄 입력 전) 저장분 그대로(473). */
|
||||
export function listedBase(
|
||||
storedBase: AlignmentBase | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
irregularStations: IrregularStation[] | null,
|
||||
): AlignmentBase | null {
|
||||
if (!storedBase || !detail || !irregularStations) return storedBase;
|
||||
const server = detail.longitudinal.stations;
|
||||
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
||||
const list = oneStationList(server, planted);
|
||||
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
||||
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
||||
const gone = server
|
||||
.filter((station) => station.kind === "irregular" && !station.grid)
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
||||
const born = list
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||
const moved = followMovedNodes(
|
||||
storedBase.basePvi,
|
||||
gone,
|
||||
born,
|
||||
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
|
||||
);
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
// 계획선 측점 줄 = logic(511-B B-3 · 서버 Node 묶음도 같은 셈) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { listedBase } from "./logic/design_planted_base";
|
||||
|
||||
@@ -6,35 +6,10 @@
|
||||
* 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import type { PlannedStation } from "../../common_util/common_util_structure_station";
|
||||
// 측점 계획 → 종단 측점 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { plannedIrregular } from "./logic/design_planted_base";
|
||||
import { useDesignStore, type DesignStoreValues } from "./design_store";
|
||||
|
||||
/**
|
||||
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||
*/
|
||||
export function plannedIrregular(
|
||||
planned: ReadonlyArray<PlannedStation>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
const interval = intervalM > 0 ? intervalM : 20;
|
||||
return planned.map((station) => {
|
||||
const owner = station.owners[0];
|
||||
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||
const number = Math.floor(station.chainage_m / interval);
|
||||
return {
|
||||
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||
station: number,
|
||||
remainder: station.chainage_m - number * interval,
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.title,
|
||||
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 바뀜 신호 하나(323-6) — 관 · 구조물 · 추가 측점 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
|
||||
* 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } ·
|
||||
* berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } ·
|
||||
* rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) ·
|
||||
* profile? · carry_profile?(종단 · `design_calc_node_profile.ts` · 511-B B-3) ·
|
||||
* stages?(묶음 단계 · `design_calc_node_stages.ts` · 511-B) } }
|
||||
* 출력 { results: { [id]: 값 }, available: [id…] }
|
||||
* — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 ·
|
||||
@@ -43,12 +44,13 @@ import {
|
||||
} from "../../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { carryRunners, type CarryInputs } from "./design_calc_node_carry";
|
||||
import { followRunners, type FollowInputs } from "./design_calc_node_follow";
|
||||
import { profileRunners, type ProfileInputs } from "./design_calc_node_profile";
|
||||
import { STAGE_IDS, stageRunners, type StagesInputs } from "./design_calc_node_stages";
|
||||
import { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial";
|
||||
|
||||
type HaulOptions = NonNullable<Parameters<typeof computeHaulPlan>[2]>;
|
||||
|
||||
interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs, StagesInputs {
|
||||
interface CalcInputs extends FollowInputs, ProfileInputs, CarryInputs, TrialInputs, StagesInputs {
|
||||
detail?: SectionDetailResponse;
|
||||
route_points?: RoutePoint[];
|
||||
context?: Partial<
|
||||
@@ -145,6 +147,7 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
|
||||
const RUNNERS: Record<string, () => unknown> = {
|
||||
...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①)
|
||||
...profileRunners(inputs), // 첫 계획선 · 계획선 짓기 · 계획고 옮김(511-B B-3)
|
||||
...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B)
|
||||
...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A)
|
||||
...stageRunners(inputs), // 묶음 단계 노선 ~ 횡단(511-B B-1)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_profile.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 종단 몫(PLAN 511-B B-3) — 첫 계획선 꼭짓점 · 계획선 짓기 ·
|
||||
* 노선 바뀜 계획고 옮김. 브라우저 묶음이 쓰는 같은 TS · 파이썬은 `B05_Profile_Engine_Grade_Node.py` 로
|
||||
* (늘 떠 있는 Node · 출력 = 반올림 전 값 · 정본 꼴 0.000001 m 는 파이썬이 적음).
|
||||
*
|
||||
* profile.base 입력 profile { chainage · ground(파이썬 `ground_profile`) · policy(`AlignmentPolicy.as_dict`) ·
|
||||
* ground_following { stations: [측점 m] · start_offset_m · end_offset_m } } →
|
||||
* 꼭짓점(`groundFollowingNodes` · 반올림 전) · 못 서는 땅 null(파이썬 2~4 순위)
|
||||
* profile.alignment 입력 profile { chainage · ground · stations: [{ station_id · chainage_m }] · policy ·
|
||||
* base_pvi(없으면 profile.base 출력) · edits · only_explicit_curves } → `buildAlignment`
|
||||
* profile.carry 입력 carry_profile { old_xy · new_xy · old_base · edits · stations · half_m ·
|
||||
* seat?{ ground_s · ground_z · policy · base_pvi · born } } →
|
||||
* { edits(`carryEdits`) · base_pvi(`seatRouteMoved` · 높이 고친 측점 = 옮긴 편집 열쇠) }
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildAlignment,
|
||||
emptyEdits,
|
||||
type AlignmentEdits,
|
||||
type AlignmentNode,
|
||||
type AlignmentPolicy,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { groundFollowingNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Ground";
|
||||
import { carryEdits } from "../../../B05_Profile/logic/B05_Profile_Grade_Carry";
|
||||
import { seatRouteMoved } from "../../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
|
||||
type Line = Array<[number, number]>;
|
||||
const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y }));
|
||||
|
||||
export interface ProfileInputs {
|
||||
profile?: {
|
||||
chainage: number[];
|
||||
ground: number[];
|
||||
policy: AlignmentPolicy;
|
||||
stations?: Array<{ station_id: string | null; chainage_m: number }>;
|
||||
base_pvi?: AlignmentNode[] | null;
|
||||
edits?: AlignmentEdits | null;
|
||||
only_explicit_curves?: boolean;
|
||||
ground_following?: { stations: number[]; start_offset_m?: number; end_offset_m?: number };
|
||||
};
|
||||
carry_profile?: {
|
||||
old_xy: Line;
|
||||
new_xy: Line;
|
||||
old_base: AlignmentNode[];
|
||||
edits?: Partial<AlignmentEdits> | null;
|
||||
stations: number[];
|
||||
half_m: number;
|
||||
seat?: {
|
||||
ground_s: number[];
|
||||
ground_z: number[];
|
||||
policy: AlignmentPolicy;
|
||||
base_pvi: AlignmentNode[];
|
||||
born: number[];
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음) — 계획선은 꼭짓점이 없으면 같은 요청의 첫 계획선 꼭짓점 위. */
|
||||
export function profileRunners(inputs: ProfileInputs): Record<string, () => unknown> {
|
||||
let nodes: AlignmentNode[] | null | undefined;
|
||||
const base = (): AlignmentNode[] | null => {
|
||||
const profile = inputs.profile;
|
||||
const follow = profile?.ground_following;
|
||||
if (nodes === undefined)
|
||||
nodes =
|
||||
profile && follow
|
||||
? groundFollowingNodes(profile.chainage, profile.ground, {
|
||||
stations: follow.stations,
|
||||
policy: profile.policy,
|
||||
startOffsetM: follow.start_offset_m ?? 0,
|
||||
endOffsetM: follow.end_offset_m ?? 0,
|
||||
})
|
||||
: null;
|
||||
return nodes;
|
||||
};
|
||||
return {
|
||||
"profile.base": base,
|
||||
"profile.alignment": () => {
|
||||
const profile = inputs.profile;
|
||||
const basePvi = profile?.base_pvi ?? base();
|
||||
if (!profile || !basePvi) return null;
|
||||
return buildAlignment(
|
||||
{
|
||||
policy: profile.policy,
|
||||
onlyExplicitCurves: profile.only_explicit_curves === true,
|
||||
basePvi,
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
stations: profile.stations ?? [],
|
||||
},
|
||||
profile.edits ?? emptyEdits(),
|
||||
);
|
||||
},
|
||||
"profile.carry": () => {
|
||||
const carry = inputs.carry_profile;
|
||||
if (!carry) return null;
|
||||
const edits = carryEdits(
|
||||
xyOf(carry.old_xy),
|
||||
xyOf(carry.new_xy),
|
||||
carry.old_base,
|
||||
carry.edits,
|
||||
carry.stations,
|
||||
carry.half_m,
|
||||
);
|
||||
const seat = carry.seat;
|
||||
if (!seat) return { edits, base_pvi: null };
|
||||
const held = Object.keys(edits.station_offsets).map(Number);
|
||||
const base = {
|
||||
chainage: seat.ground_s,
|
||||
ground: seat.ground_z,
|
||||
policy: seat.policy,
|
||||
basePvi: seat.base_pvi,
|
||||
};
|
||||
return { edits, base_pvi: seatRouteMoved(base, seat.born, held) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import { readAlignment } from "../../../B05_Profile/logic/B05_Profile_UI_Profile
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createCrossStage } from "../../../B06_Section/logic/B06_Section_Cross_Stage";
|
||||
import { pipeBook } from "../../../B06_Section/B06_Section_Route_Carry_Patch";
|
||||
import { plantedBase } from "../design_pipeline_base";
|
||||
import { plantedBase } from "./design_planted_base";
|
||||
import { createDesignPipeline } from "./design_pipeline";
|
||||
import { contextCross, placedFollowInputs } from "./design_pipeline_stage_inputs";
|
||||
import { createStationsStage, snapOptions } from "./design_pipeline_stations";
|
||||
@@ -152,7 +152,10 @@ async function runStages(
|
||||
remote: () => Promise.reject(new Error("DTM 격자 없음 — 서버 표본 길은 파이썬")),
|
||||
}),
|
||||
planline: createPlanlineStage({
|
||||
savedBase: (at) => plantedBase({ base: null, detail }, at, types, book.at),
|
||||
savedBase: (at) =>
|
||||
plantedBase({ base: null, detail }, at, types, (pipes) =>
|
||||
pipes.map((pipe) => ({ ...pipe, id: book.at(pipe.chainage_m) })),
|
||||
),
|
||||
savedLine,
|
||||
storedPvi: () => (stored ? toAlignmentBase(stored).basePvi : null),
|
||||
}),
|
||||
|
||||
@@ -260,11 +260,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "planline",
|
||||
ts: `${B05L}B05_Profile_Grade_Ground.ts#groundFollowingBasePvi`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Profile.py#rebuild_alignment_profile`,
|
||||
until: "472-4",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Profile.py#rebuild_alignment_profile`,
|
||||
mirror: ["test_344_2_route_pairs", "test_432_1_curve_law"],
|
||||
note: "파이썬 짝은 추천 엔진(RouteInitial_Profile)도 씀 — 짝 남음",
|
||||
runner: "node:profile.base",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Profile.py#straight_pass_through`,
|
||||
mirror: ["test_344_2_route_pairs", "test_432_1_curve_law", "test_511_b3_profile_node"],
|
||||
note: "1순위 = Node(511-B B-3) · 못 서면 파이썬 2~4 순위(관 정착 · 직선 분할 · 정함) · 파이썬 짝 = 추천 엔진(RouteInitial_Profile) 몫",
|
||||
},
|
||||
{
|
||||
id: "profile.alignment",
|
||||
@@ -275,15 +274,16 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "profile",
|
||||
ts: `${B05L}B05_Profile_UI_Profile_Alignment.ts#buildAlignment`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Alignment.py#evaluate`,
|
||||
until: "472-4",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#evaluate`,
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#build_alignment`,
|
||||
mirror: [
|
||||
"test_426_profile_straight",
|
||||
"test_432_1_curve_law",
|
||||
"test_213_1_hidden_station_pvi",
|
||||
"test_438_1_initial_straighten",
|
||||
"test_511_b3_profile_node",
|
||||
],
|
||||
note: "정본 꼴(0.000001 m) = 파이썬 `Engine_Grade_Node.alignment_record` · 파이썬 짝 = 2~4 순위 폴백 몫",
|
||||
},
|
||||
{
|
||||
id: "profile.curve_law",
|
||||
@@ -294,8 +294,7 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "profile",
|
||||
ts: `${B05L}B05_Profile_Profile_CurveLaw.ts#flatBends`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_CurveLaw.py#flat_bends`,
|
||||
until: "472-4",
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_CurveLaw.py#flat_bends`,
|
||||
mirror: ["test_432_1_curve_law"],
|
||||
},
|
||||
@@ -308,8 +307,7 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "profile",
|
||||
ts: `${B05L}B05_Profile_Profile_Straight.ts#isStraightBend`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Alignment.py#is_straight_bend`,
|
||||
until: "472-4",
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#is_straight_bend`,
|
||||
mirror: ["test_426_profile_straight", "test_271_straighten_pick"],
|
||||
},
|
||||
@@ -322,10 +320,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "follow",
|
||||
ts: `${B05L}B05_Profile_Grade_Carry.ts#carryEdits`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Carry.py#carry_edits`,
|
||||
until: "472-4",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Carry.py#carry_edits`,
|
||||
mirror: ["test_400_profile_carry", "test_349_0v_route_save_span"],
|
||||
runner: "node:profile.carry",
|
||||
pair: null,
|
||||
mirror: ["test_400_profile_carry", "test_349_0v_route_save_span", "test_511_b3_profile_node"],
|
||||
note: "노선 [저장] = 꼭짓점 세움(`seatRouteMoved`)과 Node 한 부름(`Engine_Grade_Node.carry_seat`)",
|
||||
},
|
||||
{
|
||||
id: "profile.seat",
|
||||
@@ -364,11 +362,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: null,
|
||||
ts: `${B05L}B05_Profile_Profile_CurveLaw.ts#verticalCurveShortfalls`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade.py#resolve_grade_options`,
|
||||
until: "472-4",
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade.py#resolve_grade_options`,
|
||||
mirror: ["test_442_2_grade_limit_mirror"],
|
||||
note: "X5 — 기준표 두 벌(화면 PROFILE_CRITERIA · 서버 FOREST_ROAD_PROFILE_CRITERIA)",
|
||||
note: "경고 = 계획선 짓기 안 · 기준표 TS = `logic/B05_Profile_Grade_Criteria.ts`(X5 · 파이썬 표는 추천 · 폴백 몫 · 거울 442_2)",
|
||||
},
|
||||
/* ── 횡단 ─────────────────────────────────────────────────────────────── */
|
||||
{
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_planted_base.ts
|
||||
* 묶음 planline 의 저장 노선 종단 기준(PLAN 386-3) 셈 몫 — 화면 · 가게 · 세션 없는 순수(511-B B-3 · 서버 Node
|
||||
* 묶음 `design_calc_node_stages.ts` 도 같은 셈). 페이지 겉 = `design_pipeline_base.ts`(관 id = 세션 장부) ·
|
||||
* `design_profile_base.ts` · `design_structure_status.ts` 가 다시 내보냄.
|
||||
*
|
||||
* 종단도 `listed()`(design_profile)와 같은 셈 — 저장 상세 측점 + 구조물 · 관이 심는 측점(`listedBase`).
|
||||
* 심는 측점 = 종단 보기 구조물 칸(`syncCrossDrainStations`) · 횡단 보기(`planMarkStations`)와 같은
|
||||
* `planStations` → `plannedIrregular` — 보기가 종단도에 늦게 심어도 묶음은 다시 안 돎.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { AlignmentBase } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { toAlignmentBase } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "../../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_Profile_Planted";
|
||||
import {
|
||||
followMovedNodes,
|
||||
oneStationList,
|
||||
planStations,
|
||||
type PlannedStation,
|
||||
type PlanType,
|
||||
} from "../../../common_util/common_util_structure_station";
|
||||
import { readAlignment } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat";
|
||||
import type { PipelineInputs } from "./design_pipeline_types";
|
||||
|
||||
/**
|
||||
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||
*/
|
||||
export function plannedIrregular(
|
||||
planned: ReadonlyArray<PlannedStation>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
const interval = intervalM > 0 ? intervalM : 20;
|
||||
return planned.map((station) => {
|
||||
const owner = station.owners[0];
|
||||
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||
const number = Math.floor(station.chainage_m / interval);
|
||||
return {
|
||||
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||
station: number,
|
||||
remainder: station.chainage_m - number * interval,
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.title,
|
||||
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · `seatBornNodes` · 427) ·
|
||||
* 늘 저장분에서 셈. 심은 측점이 아직 없으면(null · 시설 줄 입력 전) 저장분 그대로(473). */
|
||||
export function listedBase(
|
||||
storedBase: AlignmentBase | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
irregularStations: IrregularStation[] | null,
|
||||
): AlignmentBase | null {
|
||||
if (!storedBase || !detail || !irregularStations) return storedBase;
|
||||
const server = detail.longitudinal.stations;
|
||||
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
||||
const list = oneStationList(server, planted);
|
||||
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
||||
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
||||
const gone = server
|
||||
.filter((station) => station.kind === "irregular" && !station.grid)
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
||||
const born = list
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||
const moved = followMovedNodes(
|
||||
storedBase.basePvi,
|
||||
gone,
|
||||
born,
|
||||
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
|
||||
);
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장 상세(종단도가 그리는 것) + 지금 입력이 심는 측점 = 저장 노선 종단 기준. 상세가 없으면 종단도 기준.
|
||||
* `ids` = 관에 관 id 붙임(페이지 = 세션 장부 · 서버 Node = 장부 사본 · 511-B). */
|
||||
export function plantedBase(
|
||||
source: { base: AlignmentBase | null; detail?: SectionDetailResponse | null },
|
||||
inputs: Pick<PipelineInputs, "pipes" | "structures">,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
ids: (pipes: Array<{ chainage_m: number }>) => Array<{ chainage_m: number; id: string }>,
|
||||
): AlignmentBase | null {
|
||||
const detail = source.detail ?? null;
|
||||
const stored = detail && readAlignment(detail.longitudinal);
|
||||
if (!detail || !stored) return source.base;
|
||||
const pipes = ids((inputs.pipes ?? []) as Array<{ chainage_m: number }>);
|
||||
const structures = (inputs.structures ?? []) as Parameters<typeof planStations>[1];
|
||||
const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관
|
||||
return listedBase(toAlignmentBase(stored), detail, planted);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
(측점 스냅이 뒤 교점을 밀어도 이어받음).
|
||||
- 자동 꼭짓점(`carry_base`) · 편집(`carry_edits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면
|
||||
옛 선 그 자리 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 ·
|
||||
R 은 가까운 측점.
|
||||
R 은 가까운 측점. 편집 셈 = TS `carryEdits`(서버 Node · 511-B B-3) — 파이썬 짝 없음.
|
||||
|
||||
짝 = TS `B05_Profile/logic/B05_Profile_Grade_Carry.ts`(같은 규칙) · 거울 시험 =
|
||||
`resources/tester/test_400_profile_carry.py` · `test_344_2_route_pairs.py`.
|
||||
@@ -25,14 +25,10 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key, resolve_pvi
|
||||
|
||||
# 같은 자리로 볼 옆 어긋남(m) — 브레인 정함 10-17 · 바꿀 수 있음 · TS `SAME_LINE_TOL_M` 짝.
|
||||
SAME_LINE_TOL_M = 1.0
|
||||
# 같은 자리로 볼 방향 차(cos 30°) — 엇갈려 지나는 선 · 머리핀 맞은편을 뺌.
|
||||
_SAME_DIRECTION_COS = 0.8660254037844386
|
||||
# 꼭짓점 위로 볼 거리(m) — `SAME_STATION_SNAP_M` 짝.
|
||||
_ON_NODE_M = 0.006
|
||||
# 같은 정점으로 볼 거리(m) — 저장 좌표 0.0001 m 자리 · 원호 다시 뽑기 잡음보다 크게.
|
||||
SAME_VERTEX_M = 1e-3
|
||||
_EPS = 1e-6
|
||||
@@ -153,58 +149,18 @@ def carry_edits(
|
||||
edits: dict[str, Any] | None,
|
||||
stations: list[float],
|
||||
half_m: float,
|
||||
tol_m: float = SAME_LINE_TOL_M,
|
||||
) -> dict[str, dict[str, float]]:
|
||||
"""새 편집 — 새 측점마다 같은 자리 · 옛 사용자 꼭짓점이 닿은 자리면 옛 계획선 − 옛 자동선.
|
||||
|
||||
닿은 자리 = 꼭짓점 위(그 꼭짓점이 사용자) · 앞뒤 꼭짓점 하나가 사용자. 종단곡선 R = 옛 키에서
|
||||
`half_m` 안 가장 가까운 같은 자리 측점. 새 자동 꼭짓점이 같은 자리에서 옛 자동선
|
||||
높이(`carry_base`)라 계획고가 그대로.
|
||||
`half_m` 안 가장 가까운 같은 자리 측점. 셈 = TS `carryEdits` 한 벌(서버 Node `profile.carry` ·
|
||||
511-B B-3) — 노선 [저장]은 꼭짓점 세움과 한 부름(`carry_seat`).
|
||||
"""
|
||||
out: dict[str, dict[str, float]] = {"station_offsets": {}, "curve_radii": {}}
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat
|
||||
|
||||
if not old_base:
|
||||
return out
|
||||
match = line_matcher(old_xy, new_xy, tol_m)
|
||||
mapped: list[tuple[float, float]] = []
|
||||
for t in sorted({round(float(v), 3) for v in stations if math.isfinite(float(v))}):
|
||||
s = match(t)
|
||||
if s is not None:
|
||||
mapped.append((t, s))
|
||||
base_s = np.array([float(n["chainage_m"]) for n in old_base], dtype=np.float64)
|
||||
base_z = np.array([float(n["elevation_m"]) for n in old_base], dtype=np.float64)
|
||||
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, (edits or {}).get("station_offsets") or {})
|
||||
|
||||
def touched(s: float) -> bool:
|
||||
on = np.nonzero(np.abs(pvi_s - s) < _ON_NODE_M)[0]
|
||||
if len(on):
|
||||
return sources[int(on[0])] == "user"
|
||||
ahead = np.nonzero(pvi_s > s)[0]
|
||||
nxt = int(ahead[0]) if len(ahead) else -1
|
||||
return nxt > 0 and (sources[nxt - 1] == "user" or sources[nxt] == "user")
|
||||
|
||||
for t, s in mapped:
|
||||
if touched(s):
|
||||
out["station_offsets"][chainage_key(t)] = round(
|
||||
float(np.interp(s, pvi_s, pvi_z)) - float(np.interp(s, base_s, base_z)), 6
|
||||
)
|
||||
gaps: dict[str, float] = {}
|
||||
for key, value in ((edits or {}).get("curve_radii") or {}).items():
|
||||
try:
|
||||
chainage = float(key)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not math.isfinite(chainage) or value is None:
|
||||
continue
|
||||
slot, best = None, math.inf
|
||||
for t, s in mapped:
|
||||
gap = round(abs(s - chainage), 3) # mm — 가운데 같은 거리면 앞 측점
|
||||
if gap <= half_m and gap < best:
|
||||
slot, best = chainage_key(t), gap
|
||||
if slot is None or best >= gaps.get(slot, math.inf):
|
||||
continue
|
||||
gaps[slot] = best
|
||||
out["curve_radii"][slot] = float(value)
|
||||
return out
|
||||
return {"station_offsets": {}, "curve_radii": {}}
|
||||
return carry_seat(old_xy, new_xy, old_base, edits, stations, half_m)[0]
|
||||
|
||||
|
||||
def carry_policy(
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""종단 계획선 셈 = 서버 Node(PLAN 511-B B-3) — 파이썬은 입력을 모으고 정본 꼴만 적음.
|
||||
|
||||
셈 = TS 한 벌(`design_calc_node_profile.ts` → `B05_Profile/logic/` 의
|
||||
`B05_Profile_UI_Profile_Alignment.ts` `buildAlignment` · `B05_Profile_Grade_Ground.ts`
|
||||
`groundFollowingNodes` · `B05_Profile_Grade_Carry.ts` `carryEdits` · `B05_Profile_Pipeline_Grade.ts`
|
||||
`seatRouteMoved`) · 늘 떠 있는 Node(`run_calc_served`).
|
||||
Node 출력은 반올림 전 값 — 정본 꼴(0.000001 m · 칸 차례 · 실수형)은 `alignment_record` 가 적음
|
||||
(옛 파이썬 `build_alignment` 출력과 같은 글). 파이썬 `build_alignment` 은 1순위 못 선 땅의
|
||||
2~4 순위 폴백(관 정착 · 직선 분할)만 씀. 시험 = `resources/tester/test_511_b3_profile_node.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ALIGNMENT_SCHEMA_VERSION
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
|
||||
|
||||
def run_profile(ids: list[str], inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""종단 id 를 한 요청으로 · Node 못 돌림 · 셈 실패는 RuntimeError(파이썬 짝 없음)."""
|
||||
answer = run_calc_served(ids, inputs)
|
||||
if not answer or "results" not in answer:
|
||||
reason = (answer or {}).get("error") or "Node 응답 없음"
|
||||
raise RuntimeError(f"종단 계산 Node 실패({', '.join(ids)}) — {reason}")
|
||||
return answer["results"]
|
||||
|
||||
|
||||
def _r6(value: Any) -> float:
|
||||
return round(float(value), 6)
|
||||
|
||||
|
||||
def alignment_record(
|
||||
raw: dict[str, Any], policy: dict[str, Any], *, only_explicit_curves: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Node `buildAlignment` 출력 → 저장 정본 꼴(옛 `build_alignment` 반환 · 칸 · 차례 · 반올림)."""
|
||||
segments = [
|
||||
{
|
||||
"index": int(row["index"]),
|
||||
"from_m": _r6(row["from_m"]),
|
||||
"to_m": _r6(row["to_m"]),
|
||||
"length_m": _r6(row["length_m"]),
|
||||
"height_m": _r6(row["height_m"]),
|
||||
"grade_percent": _r6(row["grade_percent"]),
|
||||
}
|
||||
for row in raw["segments"]
|
||||
]
|
||||
pvi = []
|
||||
for index, row in enumerate(raw["pvi"]):
|
||||
pvi.append(
|
||||
{
|
||||
"chainage_m": _r6(row["chainage_m"]),
|
||||
"elevation_m": _r6(row["elevation_m"]),
|
||||
"source": row["source"],
|
||||
"kind": row["kind"],
|
||||
"grade_in_pct": _r6(segments[index - 1]["grade_percent"]) if index else None,
|
||||
"grade_out_pct": (
|
||||
_r6(segments[index]["grade_percent"]) if index < len(segments) else None
|
||||
),
|
||||
"curve_l_m": None if row["curve_l_m"] is None else _r6(row["curve_l_m"]),
|
||||
"curve_r_m": None if row["curve_r_m"] is None else _r6(row["curve_r_m"]),
|
||||
}
|
||||
)
|
||||
max_grade = float(policy["max_grade_pct"])
|
||||
stations = []
|
||||
for row in raw["stations"]:
|
||||
difference = float(row["ground_elevation_m"]) - float(row["plan_elevation_m"])
|
||||
stations.append(
|
||||
{
|
||||
"station_id": row["station_id"],
|
||||
"chainage_m": _r6(row["chainage_m"]),
|
||||
"distance_m": _r6(row["distance_m"]),
|
||||
"ground_elevation_m": _r6(row["ground_elevation_m"]),
|
||||
"plan_elevation_m": _r6(row["plan_elevation_m"]),
|
||||
"cut_m": _r6(max(difference, 0.0)),
|
||||
"fill_m": _r6(max(-difference, 0.0)),
|
||||
}
|
||||
)
|
||||
balance = raw["balance"]
|
||||
cut, fill = float(balance["cut_area_m2"]), float(balance["fill_area_m2"])
|
||||
return {
|
||||
"schema_version": ALIGNMENT_SCHEMA_VERSION,
|
||||
"policy": policy,
|
||||
"only_explicit_curves": bool(only_explicit_curves),
|
||||
"base_pvi": [
|
||||
{"chainage_m": _r6(node["chainage_m"]), "elevation_m": _r6(node["elevation_m"])}
|
||||
for node in raw["base_pvi"]
|
||||
],
|
||||
"edits": {
|
||||
name: {str(key): float(value) for key, value in raw["edits"][name].items()}
|
||||
for name in ("station_offsets", "curve_radii")
|
||||
},
|
||||
"pvi": pvi,
|
||||
"segments": segments,
|
||||
"curves": [
|
||||
{
|
||||
"pvi_index": int(curve["pvi_index"]),
|
||||
**{
|
||||
name: _r6(curve[name])
|
||||
for name in (
|
||||
"chainage_m",
|
||||
"bvc_m",
|
||||
"evc_m",
|
||||
"bvc_elevation_m",
|
||||
"evc_elevation_m",
|
||||
"l_m",
|
||||
"r_m",
|
||||
"k",
|
||||
"delta_pct",
|
||||
"middle_ordinate_m",
|
||||
)
|
||||
},
|
||||
"omitted": bool(curve["omitted"]),
|
||||
"skip_allowed": bool(curve["skip_allowed"]),
|
||||
"omit_reason": curve["omit_reason"],
|
||||
}
|
||||
for curve in raw["curves"]
|
||||
],
|
||||
"stations": stations,
|
||||
"samples": [
|
||||
{
|
||||
"chainage_m": _r6(sample["chainage_m"]),
|
||||
"elevation_m": _r6(sample["elevation_m"]),
|
||||
"ground_elevation_m": _r6(sample["ground_elevation_m"]),
|
||||
"difference_m": _r6(
|
||||
float(sample["elevation_m"]) - float(sample["ground_elevation_m"])
|
||||
),
|
||||
}
|
||||
for sample in raw["samples"]
|
||||
],
|
||||
"balance": {
|
||||
"cut_area_m2": _r6(cut),
|
||||
"fill_area_m2": _r6(fill),
|
||||
"net_area_m2": _r6(fill - cut),
|
||||
"imbalance_percent": _r6(balance["imbalance_percent"]),
|
||||
"tolerance_percent": policy["balance_tolerance_percent"],
|
||||
"within_tolerance": bool(balance["within_tolerance"]),
|
||||
},
|
||||
"violations": [
|
||||
{
|
||||
"segment_index": row["index"],
|
||||
"type": "grade_over",
|
||||
"value": row["grade_percent"],
|
||||
"limit": policy["max_grade_pct"],
|
||||
}
|
||||
for row in segments
|
||||
if abs(row["grade_percent"]) > max_grade + 1e-6
|
||||
],
|
||||
"warnings": list(raw["warnings"]),
|
||||
}
|
||||
|
||||
|
||||
def node_alignment(
|
||||
chainage: Any,
|
||||
ground: Any,
|
||||
stations: list[dict[str, Any]],
|
||||
policy: dict[str, Any],
|
||||
edits: dict[str, Any] | None,
|
||||
*,
|
||||
base_pvi: list[dict[str, Any]] | None = None,
|
||||
ground_following: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""계획선 정본 — 꼭짓점(`base_pvi`) 또는 첫 계획선(`ground_following`) 위에 편집을 얹음.
|
||||
|
||||
첫 계획선이 못 서는 땅이면 None(부르는 쪽 2~4 순위). 편집 None 값 · 글 값은 옛 파이썬처럼 거름.
|
||||
"""
|
||||
edits = edits or {}
|
||||
clean = {
|
||||
name: {
|
||||
str(key): float(value)
|
||||
for key, value in (edits.get(name) or {}).items()
|
||||
if value is not None
|
||||
}
|
||||
for name in ("station_offsets", "curve_radii")
|
||||
}
|
||||
profile: dict[str, Any] = {
|
||||
"chainage": [float(value) for value in chainage],
|
||||
"ground": [float(value) for value in ground],
|
||||
"policy": policy,
|
||||
"stations": [
|
||||
{"station_id": row.get("station_id"), "chainage_m": float(row["chainage_m"])}
|
||||
for row in stations
|
||||
],
|
||||
"edits": clean,
|
||||
}
|
||||
if base_pvi is not None:
|
||||
profile["base_pvi"] = [
|
||||
{"chainage_m": float(node["chainage_m"]), "elevation_m": float(node["elevation_m"])}
|
||||
for node in base_pvi
|
||||
]
|
||||
if ground_following is not None:
|
||||
profile["ground_following"] = ground_following
|
||||
raw = run_profile(["profile.alignment"], {"profile": profile})["profile.alignment"]
|
||||
return None if raw is None else alignment_record(raw, policy)
|
||||
|
||||
|
||||
def carry_seat(
|
||||
old_xy: list[Any],
|
||||
new_xy: list[Any],
|
||||
old_base: list[dict[str, Any]],
|
||||
edits: dict[str, Any] | None,
|
||||
stations: list[float],
|
||||
half_m: float,
|
||||
seat: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, dict[str, float]], list[dict[str, Any]] | None]:
|
||||
"""노선 바뀜 계획고 옮김 + 옮긴 시설 꼭짓점 세움 = Node 한 부름(`profile.carry`).
|
||||
|
||||
`seat` = { ground_s · ground_z · policy · base_pvi · born } · 없으면 꼭짓점 None.
|
||||
"""
|
||||
carry = {
|
||||
"old_xy": [[float(x), float(y)] for x, y in old_xy],
|
||||
"new_xy": [[float(x), float(y)] for x, y in new_xy],
|
||||
"old_base": old_base,
|
||||
"edits": edits or {},
|
||||
"stations": [float(value) for value in stations],
|
||||
"half_m": float(half_m),
|
||||
"seat": seat,
|
||||
}
|
||||
out = run_profile(["profile.carry"], {"carry_profile": carry})["profile.carry"]
|
||||
moved = {
|
||||
name: {str(key): float(value) for key, value in out["edits"][name].items()}
|
||||
for name in ("station_offsets", "curve_radii")
|
||||
}
|
||||
return moved, out["base_pvi"]
|
||||
@@ -48,6 +48,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
|
||||
evaluate,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends, straighten_held
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Node import node_alignment
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Solver import (
|
||||
grade_limits,
|
||||
integration_weights,
|
||||
@@ -275,18 +276,22 @@ def design_ground_following_profile(
|
||||
if note:
|
||||
warnings.append(note)
|
||||
|
||||
# 초기값 직선화(438) — 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위
|
||||
base_z = straight_pass_through(base_s, base_z, policy)
|
||||
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=stations,
|
||||
policy=policy,
|
||||
edits=dict(edits or {}),
|
||||
# 꼭짓점(초기값 직선화 438 · 원지반 지남) · 선형 = 서버 Node 한 부름(511-B B-3 · TS
|
||||
# `groundFollowingNodes` → `buildAlignment`) · 못 서는 땅이면 ValueError → 2~4 순위 폴백
|
||||
alignment = node_alignment(
|
||||
chainage,
|
||||
ground,
|
||||
stations,
|
||||
policy.as_dict(),
|
||||
edits,
|
||||
ground_following={
|
||||
"stations": [float(value) for value in base_s],
|
||||
"start_offset_m": float(options.start_elevation_offset_m),
|
||||
"end_offset_m": float(options.end_elevation_offset_m),
|
||||
},
|
||||
)
|
||||
if alignment is None:
|
||||
raise ValueError("첫 계획선 꼭짓점을 세울 수 없는 종단입니다.")
|
||||
warnings.extend(alignment["warnings"])
|
||||
balanced = bool(alignment["balance"]["within_tolerance"])
|
||||
return alignment, _profile_entry(
|
||||
@@ -549,8 +554,6 @@ def rebuild_alignment_profile(
|
||||
|
||||
chainage, ground = ground_profile(longitudinal)
|
||||
base = stored["base_pvi"]
|
||||
base_s = np.array([float(item["chainage_m"]) for item in base], dtype=np.float64)
|
||||
base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64)
|
||||
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
||||
|
||||
previous = (longitudinal.get("design_profiles") or [{}])[0]
|
||||
@@ -564,14 +567,14 @@ def rebuild_alignment_profile(
|
||||
min_curve_length_m=policy.min_curve_length_m
|
||||
or float(criteria.get("min_curve_length_m") or 0.0),
|
||||
)
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=list(longitudinal.get("stations") or []),
|
||||
policy=policy,
|
||||
edits=edits,
|
||||
# 선형 = 서버 Node(511-B B-3 · TS `buildAlignment`) · 정본 꼴은 `alignment_record`
|
||||
alignment = node_alignment(
|
||||
chainage,
|
||||
ground,
|
||||
list(longitudinal.get("stations") or []),
|
||||
policy.as_dict(),
|
||||
edits,
|
||||
base_pvi=base,
|
||||
)
|
||||
options = GradeDesignOptions(
|
||||
max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct),
|
||||
|
||||
@@ -52,7 +52,6 @@ from B05_Profile.B05_Profile_Route_Save_Parts import (
|
||||
carry_cross_inputs,
|
||||
follow_pipes,
|
||||
route_moved_stations,
|
||||
seat_route_moved,
|
||||
station_keys,
|
||||
station_seats,
|
||||
vertices_of,
|
||||
@@ -255,12 +254,9 @@ async def _carry_profile(
|
||||
new_xy: list[list[float]],
|
||||
) -> None:
|
||||
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400)."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import (
|
||||
carry_base,
|
||||
carry_edits,
|
||||
carry_policy,
|
||||
changed_span,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_policy, changed_span
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat
|
||||
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
|
||||
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
@@ -278,13 +274,22 @@ async def _carry_profile(
|
||||
stations = [float(row["chainage_m"]) for row in longitudinal.get("stations") or []]
|
||||
half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2
|
||||
base = carry_base(old_xy, new_xy, old_base, alignment["base_pvi"])
|
||||
edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half)
|
||||
alignment["policy"] = carry_policy(old_xy, new_xy, old_alignment, alignment) # 옛 L · 열쇠(432)
|
||||
alignment["base_pvi"] = base
|
||||
if span is not None: # 옮긴 시설 측점에 427 꼭짓점 다시 세움(459 · 초안 planline 짝)
|
||||
born = route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy)
|
||||
held = [float(key) for key in (edits.get("station_offsets") or {})]
|
||||
alignment["base_pvi"] = seat_route_moved(alignment, born, held)
|
||||
if (
|
||||
span is not None
|
||||
): # 편집 옮김 + 옮긴 시설 측점 427 꼭짓점 다시 세움 = Node 한 부름(459 · 511-B)
|
||||
samples = alignment.get("samples") or []
|
||||
seat = {
|
||||
"ground_s": [float(row["chainage_m"]) for row in samples],
|
||||
"ground_z": [float(row["ground_elevation_m"]) for row in samples],
|
||||
"policy": AlignmentPolicy.from_dict(alignment["policy"] or {}).as_dict(),
|
||||
"base_pvi": base,
|
||||
"born": route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy),
|
||||
}
|
||||
edits, alignment["base_pvi"] = await asyncio.to_thread(
|
||||
carry_seat, old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half, seat
|
||||
)
|
||||
await asyncio.to_thread(atomic_write_json, path, longitudinal)
|
||||
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||
_apply_alignment_edits, project_root, relative, edits
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { stationPartsLabel } from "../common_util/common_util_station";
|
||||
import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog";
|
||||
import {
|
||||
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
|
||||
@@ -26,6 +25,10 @@ import {
|
||||
} from "@config/config_frontend";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
|
||||
// 그래프 측점 id · 측점 표기 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
import { irregularLabel } from "./logic/B05_Profile_Profile_Planted";
|
||||
export { irregularLabel, irregularStationId } from "./logic/B05_Profile_Profile_Planted";
|
||||
|
||||
export interface IrregularStation {
|
||||
id: string;
|
||||
/** 측점번호 X. */
|
||||
@@ -126,16 +129,6 @@ interface IrregularStationsCallbacks {
|
||||
onSelect: (station: IrregularStation | null) => void;
|
||||
}
|
||||
|
||||
/** 비정규 측점의 그래프·3D·테이블 공용 식별자. 주입 측점의 station_id로도 쓴다. */
|
||||
export function irregularStationId(id: string): string {
|
||||
return `irregular:${id}`;
|
||||
}
|
||||
|
||||
/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */
|
||||
export function irregularLabel(station: IrregularStation): string {
|
||||
return stationPartsLabel(station.station, station.remainder);
|
||||
}
|
||||
|
||||
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import { type ButtonVariant, createButton } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
|
||||
// 법정 기준표 = logic(511-B B-3 · X5) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { PROFILE_CRITERIA, SPEED_CHOICES, gradeLimitOf } from "./logic/B05_Profile_Grade_Criteria";
|
||||
|
||||
export interface RoutePanelValues {
|
||||
contourInterval: number;
|
||||
/** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존
|
||||
@@ -32,44 +35,9 @@ export interface RoutePanelValues {
|
||||
stationDisplay: { station: number; cumulative: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */
|
||||
export const STATION_INTERVAL_M = 20;
|
||||
|
||||
/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통,
|
||||
* 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다
|
||||
* (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와
|
||||
* 실제 적용값이 어긋나지 않는다.
|
||||
*
|
||||
* ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인):
|
||||
* ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선·
|
||||
* 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침).
|
||||
* ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은
|
||||
* 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는
|
||||
* 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다.
|
||||
* ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다.
|
||||
* 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4).
|
||||
* 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */
|
||||
export const PROFILE_CRITERIA: Record<
|
||||
RoutePanelValues["designSpeed"],
|
||||
{ grade: Record<RoutePanelValues["terrainType"], number>; radius: number }
|
||||
> = {
|
||||
40: { grade: { normal: 7, special: 10 }, radius: 450 },
|
||||
30: { grade: { normal: 8, special: 12 }, radius: 250 },
|
||||
20: { grade: { normal: 9, special: 14 }, radius: 100 },
|
||||
};
|
||||
|
||||
/** 적용 종단기울기 상한(%) — 넣은 값(> 0)이면 그 값 · 비웠으면 설계속도 · 지형 법정 기준(좌측 패널 · 페이지 설정 한 칸 463). */
|
||||
export const gradeLimitOf = (
|
||||
speed: RoutePanelValues["designSpeed"],
|
||||
terrain: RoutePanelValues["terrainType"],
|
||||
typed: number | null,
|
||||
): number => (typed != null && typed > 0 ? typed : PROFILE_CRITERIA[speed].grade[terrain]);
|
||||
|
||||
/** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */
|
||||
export const ROAD_TYPE_LABELS: Record<string, string> = {
|
||||
trunk: "간선임도",
|
||||
@@ -78,14 +46,6 @@ export const ROAD_TYPE_LABELS: Record<string, string> = {
|
||||
branch: "작업임도(구 지선)",
|
||||
};
|
||||
|
||||
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
|
||||
export const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
|
||||
trunk: [20, 30, 40],
|
||||
fire: [20, 30, 40],
|
||||
work: [20],
|
||||
branch: [20],
|
||||
};
|
||||
|
||||
export type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
|
||||
@@ -8,15 +8,12 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { normalizedLongitudinal } from "./logic/B05_Profile_UI_Profile_Data";
|
||||
import { TABLE_TARGET_FONT_PX, tableCellWidthFor } from "./B05_Profile_UI_Profile_Table";
|
||||
import type { LongitudinalSection, SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
irregularLabel,
|
||||
irregularStationId,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import { PLUS_OWNER_PREFIX } from "../common_util/common_util_structure_station";
|
||||
import type { LongitudinalSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
// 최대 누가거리 · 그래프 측점 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
import { maxChainageOf } from "./logic/B05_Profile_Profile_Planted";
|
||||
export { irregularGraphStations, maxChainageOf } from "./logic/B05_Profile_Profile_Planted";
|
||||
|
||||
/** 테이블 6행(구배 S · 계획고 · 지반고 · 측점 · 곡선 L · 곡선 R). 행 높이와 셀 폭에서
|
||||
* 글자 크기를 정하는 데 쓴다. 2026-09-06 사용자 지시로 12행에서 줄였다. */
|
||||
@@ -40,39 +37,6 @@ export const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL
|
||||
*/
|
||||
export const PROFILE_SPACING_MULTIPLIER = 1.5;
|
||||
|
||||
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
|
||||
export function maxChainageOf(data: LongitudinalSection): number {
|
||||
const samples = normalizedLongitudinal(data).samples.filter(
|
||||
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
|
||||
);
|
||||
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만
|
||||
* 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외.
|
||||
*/
|
||||
export function irregularGraphStations(
|
||||
list: IrregularStation[],
|
||||
maxChainage: number,
|
||||
): SectionStation[] {
|
||||
return list
|
||||
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
|
||||
.map((entry) => ({
|
||||
station_id: irregularStationId(entry.id),
|
||||
chainage_m: entry.chainage_m,
|
||||
label: irregularLabel(entry),
|
||||
kind: "irregular" as const,
|
||||
// 추가 측점(368-2) = 일반측점 색 점선 · 끌기 없음
|
||||
...(entry.id.startsWith(PLUS_OWNER_PREFIX) ? { plus: true } : {}),
|
||||
center_z: null,
|
||||
azimuth_deg: null,
|
||||
center_x: 0,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 0] as [number, number] },
|
||||
}));
|
||||
}
|
||||
|
||||
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
|
||||
export function chainageMapper(
|
||||
data: LongitudinalSection,
|
||||
|
||||
@@ -23,11 +23,11 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch
|
||||
import { normalizedLongitudinal, toDesignProfile } from "./logic/B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
adjustStation,
|
||||
setCurveRadius,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
type ProfileAlignment,
|
||||
} from "./logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { setCurveRadius } from "./logic/B05_Profile_UI_Profile_Alignment_Edit";
|
||||
import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit";
|
||||
import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Grade_Criteria.ts
|
||||
* 종단 법정 기준표(설계속도 × 지형 → 종단기울기 상한 · 종단곡선 반경) · 종류별 설계속도 — 화면
|
||||
* `B05_Profile_UI_Panel_Fields.ts` 에서 옮김(511-B B-3 · X5 · 순수 자료). 파이썬 표 =
|
||||
* `config_system_design.FOREST_ROAD_PROFILE_CRITERIA`(추천 · 폴백 · 기본값 해석 몫) · 거울 시험 =
|
||||
* resources/tester/test_442_2_grade_limit_mirror.py.
|
||||
* ========================================================================== */
|
||||
|
||||
export type DesignSpeed = 20 | 30 | 40;
|
||||
export type TerrainType = "normal" | "special";
|
||||
|
||||
/**
|
||||
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통,
|
||||
* 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다
|
||||
* (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와
|
||||
* 실제 적용값이 어긋나지 않는다.
|
||||
*
|
||||
* ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인):
|
||||
* ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선·
|
||||
* 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침).
|
||||
* ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은
|
||||
* 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는
|
||||
* 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다.
|
||||
* ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다.
|
||||
* 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4).
|
||||
* 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */
|
||||
export const PROFILE_CRITERIA: Record<
|
||||
DesignSpeed,
|
||||
{ grade: Record<TerrainType, number>; radius: number }
|
||||
> = {
|
||||
40: { grade: { normal: 7, special: 10 }, radius: 450 },
|
||||
30: { grade: { normal: 8, special: 12 }, radius: 250 },
|
||||
20: { grade: { normal: 9, special: 14 }, radius: 100 },
|
||||
};
|
||||
|
||||
/** 적용 종단기울기 상한(%) — 넣은 값(> 0)이면 그 값 · 비웠으면 설계속도 · 지형 법정 기준(좌측 패널 · 페이지 설정 한 칸 463). */
|
||||
export const gradeLimitOf = (
|
||||
speed: DesignSpeed,
|
||||
terrain: TerrainType,
|
||||
typed: number | null,
|
||||
): number => (typed != null && typed > 0 ? typed : PROFILE_CRITERIA[speed].grade[terrain]);
|
||||
|
||||
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
|
||||
export const SPEED_CHOICES: Record<string, Array<DesignSpeed>> = {
|
||||
trunk: [20, 30, 40],
|
||||
fire: [20, 30, 40],
|
||||
work: [20],
|
||||
branch: [20],
|
||||
};
|
||||
@@ -110,11 +110,15 @@ function fitThrough(
|
||||
return baseZ;
|
||||
}
|
||||
|
||||
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
|
||||
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
|
||||
const profile = groundProfile(input.samples);
|
||||
if (!profile) return null;
|
||||
const { chainage, ground } = profile;
|
||||
/**
|
||||
* 첫 계획선 꼭짓점(반올림 전) — 종단 지반(`groundProfile` 꼴) 위 측점마다 꼭짓점 · 서버 Node(511-B B-3)는
|
||||
* 이 값으로 선형을 짓고 파이썬이 정본 꼴(0.000001 m)로 적음(파이썬 1순위와 같은 순서).
|
||||
*/
|
||||
export function groundFollowingNodes(
|
||||
chainage: number[],
|
||||
ground: number[],
|
||||
input: Omit<GroundFollowingInput, "samples">,
|
||||
): AlignmentNode[] | null {
|
||||
const total = chainage[chainage.length - 1];
|
||||
if (!(total > 0)) return null;
|
||||
|
||||
@@ -133,8 +137,17 @@ export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNo
|
||||
target[0] += input.startOffsetM ?? 0;
|
||||
target[target.length - 1] += input.endOffsetM ?? 0;
|
||||
const baseZ = passThrough(nodeS, target, input.policy);
|
||||
return nodeS.map((s, index) => ({
|
||||
chainage_m: roundTo(s, 6),
|
||||
elevation_m: roundTo(baseZ[index], 6),
|
||||
}));
|
||||
return nodeS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
|
||||
}
|
||||
|
||||
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
|
||||
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
|
||||
const profile = groundProfile(input.samples);
|
||||
const nodes = profile && groundFollowingNodes(profile.chainage, profile.ground, input);
|
||||
return (
|
||||
nodes?.map((node) => ({
|
||||
chainage_m: roundTo(node.chainage_m, 6),
|
||||
elevation_m: roundTo(node.elevation_m, 6),
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Profile_Planted.ts
|
||||
* 종단도에 심는 비정규 측점 → 그래프 측점 · 노선 최대 누가거리(511-B B-3) — 화면
|
||||
* `B05_Profile_UI_IrregularStations.ts` · `B05_Profile_UI_Profile_Layout.ts` 에서 옮김(순수 · 두 파일이
|
||||
* 다시 내보냄). 서버 Node 묶음 planline 저장 기준(`design_planted_base.ts`)이 화면 파일을 안 끌게.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { IrregularStation } from "../B05_Profile_UI_IrregularStations";
|
||||
import type { LongitudinalSection, SectionStation } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { stationPartsLabel } from "../../common_util/common_util_station";
|
||||
import { PLUS_OWNER_PREFIX } from "../../common_util/common_util_structure_station";
|
||||
import { normalizedLongitudinal } from "./B05_Profile_UI_Profile_Data";
|
||||
|
||||
/** 비정규 측점의 그래프·3D·테이블 공용 식별자. 주입 측점의 station_id로도 쓴다. */
|
||||
export function irregularStationId(id: string): string {
|
||||
return `irregular:${id}`;
|
||||
}
|
||||
|
||||
/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */
|
||||
export function irregularLabel(station: IrregularStation): string {
|
||||
return stationPartsLabel(station.station, station.remainder);
|
||||
}
|
||||
|
||||
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
|
||||
export function maxChainageOf(data: LongitudinalSection): number {
|
||||
const samples = normalizedLongitudinal(data).samples.filter(
|
||||
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
|
||||
);
|
||||
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만
|
||||
* 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외.
|
||||
*/
|
||||
export function irregularGraphStations(
|
||||
list: IrregularStation[],
|
||||
maxChainage: number,
|
||||
): SectionStation[] {
|
||||
return list
|
||||
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
|
||||
.map((entry) => ({
|
||||
station_id: irregularStationId(entry.id),
|
||||
chainage_m: entry.chainage_m,
|
||||
label: irregularLabel(entry),
|
||||
kind: "irregular" as const,
|
||||
// 추가 측점(368-2) = 일반측점 색 점선 · 끌기 없음
|
||||
...(entry.id.startsWith(PLUS_OWNER_PREFIX) ? { plus: true } : {}),
|
||||
center_z: null,
|
||||
azimuth_deg: null,
|
||||
center_x: 0,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 0] as [number, number] },
|
||||
}));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Alignment.ts
|
||||
* 종단 계획선 선형(직선 + 측점 위 종단곡선) 프론트엔드 기하 계산.
|
||||
* 종단 계획선 선형(직선 + 측점 위 종단곡선) 프론트엔드 기하 계산 — 구간 쉬프트 · R 입력은
|
||||
* `B05_Profile_UI_Profile_Alignment_Edit.ts`(700줄 나눔 · 511-B B-3).
|
||||
*
|
||||
* 백엔드 `B05_Profile_Engine_Grade_Alignment.py`와 **같은 식**을 쓴다. 사용자가
|
||||
* 0.1m 버튼을 누를 때마다 서버를 오가지 않고 즉시 다시 그리기 위한 계산부이며,
|
||||
@@ -159,7 +160,7 @@ export function emptyEdits(): AlignmentEdits {
|
||||
}
|
||||
|
||||
/** 오름차순 x 배열 위에서의 선형 보간 (범위 밖은 양 끝값으로 클램프). */
|
||||
function interpolate(xs: number[], ys: number[], value: number): number {
|
||||
export function interpolate(xs: number[], ys: number[], value: number): number {
|
||||
if (!xs.length) return 0;
|
||||
if (value <= xs[0]) return ys[0];
|
||||
if (value >= xs[xs.length - 1]) return ys[ys.length - 1];
|
||||
@@ -391,6 +392,14 @@ function trapezoidWeights(chainage: number[]): number[] {
|
||||
}
|
||||
|
||||
/** 저장분·초안이 부분적으로 비어 있어도 계산이 터지지 않도록 편집 맵을 채운다. */
|
||||
/** `np.round(value, 6)` — 백만 배 · 짝수 쪽 반올림(rint) · 나눔(파이썬 표본 격자와 같은 값). */
|
||||
function npRound6(value: number): number {
|
||||
const scaled = value * 1e6;
|
||||
let rounded = Math.round(scaled);
|
||||
if (rounded - scaled === 0.5 && rounded % 2 !== 0) rounded -= 1; // Math.round = .5 위로
|
||||
return rounded / 1e6;
|
||||
}
|
||||
|
||||
function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits {
|
||||
return {
|
||||
station_offsets: edits?.station_offsets ?? {},
|
||||
@@ -437,7 +446,8 @@ export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): Profi
|
||||
// 격자만 쓰면 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의
|
||||
// 모서리를 선이 잘라먹어, 종단곡선 R이 안 보이고 편집 지점과 선이 어긋나 끊긴 것처럼
|
||||
// 보인다(2026-08-03 사용자 보고). 곡선 구간 끝점까지 넣어야 직선→곡선 이음이 정확히 붙는다.
|
||||
const sampleChainages = new Set<number>(base.chainage.map((value) => Number(value.toFixed(3))));
|
||||
// 격자 자리 = 0.000001 m(파이썬 `np.round(merged, 6)` 와 같은 값 — 서버 Node 정본 · 511-B B-3).
|
||||
const sampleChainages = new Set<number>(base.chainage.map(npRound6));
|
||||
const first = base.chainage[0];
|
||||
const last = base.chainage[base.chainage.length - 1];
|
||||
for (const extra of [
|
||||
@@ -445,7 +455,7 @@ export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): Profi
|
||||
...curves.flatMap((curve) => [curve.bvc_m, curve.chainage_m, curve.evc_m]),
|
||||
]) {
|
||||
if (extra >= first - 1e-6 && extra <= last + 1e-6) {
|
||||
sampleChainages.add(Number(extra.toFixed(3)));
|
||||
sampleChainages.add(npRound6(extra));
|
||||
}
|
||||
}
|
||||
const samples: AlignmentSample[] = [...sampleChainages]
|
||||
@@ -614,86 +624,6 @@ export function controlElevationAt(alignment: ProfileAlignment, chainageM: numbe
|
||||
return node ? node.elevation_m : planElevationAt(alignment, chainageM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정).
|
||||
*
|
||||
* 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트.
|
||||
* 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽
|
||||
* 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고
|
||||
* 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간:
|
||||
* 안쪽 미틸트 측점 2개 미만).
|
||||
*/
|
||||
export function shiftMovingPoints(
|
||||
alignment: ProfileAlignment,
|
||||
segment: AlignmentSegment,
|
||||
): [number, number] | null {
|
||||
const offsets = alignment.edits.station_offsets;
|
||||
const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined;
|
||||
const interior = alignment.stations
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6);
|
||||
const left = movable(segment.from_m) ? segment.from_m : interior[0];
|
||||
const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1];
|
||||
if (left === undefined || right === undefined) return null;
|
||||
if (right - left < 1e-6) return null;
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다.
|
||||
* 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다.
|
||||
* 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다.
|
||||
*/
|
||||
export function shiftSegment(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
segment: AlignmentSegment,
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
const current = buildAlignment(base, edits);
|
||||
const moving = shiftMovingPoints(current, segment);
|
||||
if (!moving) return edits;
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta);
|
||||
const offsets = { ...edits.station_offsets };
|
||||
moving.forEach((chainage) => {
|
||||
const key = chainageKey(chainage);
|
||||
const seed =
|
||||
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
|
||||
offsets[key] = Number((seed + delta).toFixed(6));
|
||||
});
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const trial = buildAlignment(base, { ...edits, station_offsets: offsets });
|
||||
let worst = 0;
|
||||
moving.forEach((chainage, index) => {
|
||||
const key = chainageKey(chainage);
|
||||
const error = targets[index] - planElevationAt(trial, chainage);
|
||||
worst = Math.max(worst, Math.abs(error));
|
||||
offsets[key] = Number((offsets[key] + error).toFixed(6));
|
||||
});
|
||||
if (worst < FEEDBACK_TOLERANCE_M) break;
|
||||
}
|
||||
return { ...edits, station_offsets: offsets };
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
|
||||
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
|
||||
* 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다.
|
||||
*/
|
||||
export function setCurveRadius(
|
||||
edits: AlignmentEdits,
|
||||
curve: AlignmentCurve,
|
||||
radiusM: number,
|
||||
): AlignmentEdits {
|
||||
const key = chainageKey(curve.chainage_m);
|
||||
const curveRadii = { ...edits.curve_radii };
|
||||
if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key];
|
||||
else curveRadii[key] = Number(radiusM.toFixed(6));
|
||||
return { ...edits, curve_radii: curveRadii };
|
||||
}
|
||||
|
||||
export function hasEdits(edits: AlignmentEdits): boolean {
|
||||
const safe = normalizeEdits(edits);
|
||||
return Object.keys(safe.station_offsets).length > 0 || Object.keys(safe.curve_radii).length > 0;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts
|
||||
* 종단 계획선 편집 도구 — 구간 평행이동(쉬프트) · 곡선 R 입력. `B05_Profile_UI_Profile_Alignment.ts`
|
||||
* 에서 나눔(700줄 · 511-B B-3) · 선형 짓기(`buildAlignment`)는 그 파일.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
interpolate,
|
||||
planElevationAt,
|
||||
type AlignmentBase,
|
||||
type AlignmentCurve,
|
||||
type AlignmentEdits,
|
||||
type AlignmentSegment,
|
||||
type ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
const FEEDBACK_PASSES = 4;
|
||||
const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
|
||||
/**
|
||||
* 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정).
|
||||
*
|
||||
* 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트.
|
||||
* 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽
|
||||
* 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고
|
||||
* 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간:
|
||||
* 안쪽 미틸트 측점 2개 미만).
|
||||
*/
|
||||
export function shiftMovingPoints(
|
||||
alignment: ProfileAlignment,
|
||||
segment: AlignmentSegment,
|
||||
): [number, number] | null {
|
||||
const offsets = alignment.edits.station_offsets;
|
||||
const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined;
|
||||
const interior = alignment.stations
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6);
|
||||
const left = movable(segment.from_m) ? segment.from_m : interior[0];
|
||||
const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1];
|
||||
if (left === undefined || right === undefined) return null;
|
||||
if (right - left < 1e-6) return null;
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다.
|
||||
* 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다.
|
||||
* 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다.
|
||||
*/
|
||||
export function shiftSegment(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
segment: AlignmentSegment,
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
const current = buildAlignment(base, edits);
|
||||
const moving = shiftMovingPoints(current, segment);
|
||||
if (!moving) return edits;
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta);
|
||||
const offsets = { ...edits.station_offsets };
|
||||
moving.forEach((chainage) => {
|
||||
const key = chainageKey(chainage);
|
||||
const seed =
|
||||
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
|
||||
offsets[key] = Number((seed + delta).toFixed(6));
|
||||
});
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const trial = buildAlignment(base, { ...edits, station_offsets: offsets });
|
||||
let worst = 0;
|
||||
moving.forEach((chainage, index) => {
|
||||
const key = chainageKey(chainage);
|
||||
const error = targets[index] - planElevationAt(trial, chainage);
|
||||
worst = Math.max(worst, Math.abs(error));
|
||||
offsets[key] = Number((offsets[key] + error).toFixed(6));
|
||||
});
|
||||
if (worst < FEEDBACK_TOLERANCE_M) break;
|
||||
}
|
||||
return { ...edits, station_offsets: offsets };
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
|
||||
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
|
||||
* 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다.
|
||||
*/
|
||||
export function setCurveRadius(
|
||||
edits: AlignmentEdits,
|
||||
curve: AlignmentCurve,
|
||||
radiusM: number,
|
||||
): AlignmentEdits {
|
||||
const key = chainageKey(curve.chainage_m);
|
||||
const curveRadii = { ...edits.curve_radii };
|
||||
if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key];
|
||||
else curveRadii[key] = Number(radiusM.toFixed(6));
|
||||
return { ...edits, curve_radii: curveRadii };
|
||||
}
|
||||
@@ -38,6 +38,9 @@ NODE_IDS = frozenset(
|
||||
"excavation.trench",
|
||||
"corridor.mesh",
|
||||
"profile.seat",
|
||||
"profile.base",
|
||||
"profile.alignment",
|
||||
"profile.carry",
|
||||
"facility.plus_follow",
|
||||
"facility.follow",
|
||||
"cross.design",
|
||||
|
||||
@@ -29,6 +29,7 @@ const REAL = new Set(
|
||||
"B05_Profile/B05_Profile_Api_PageSettings.ts",
|
||||
"B05_Profile/B05_Profile_UI_Page_Latest.ts",
|
||||
"B05_Profile/B05_Profile_UI_Panel_Fields.ts",
|
||||
"B05_Profile/logic/B05_Profile_Grade_Criteria.ts", // 법정 기준표(511-B B-3 · 화면에서 옮김)
|
||||
].map((rel) => path.join(ROOT, rel)),
|
||||
);
|
||||
const anything = () =>
|
||||
|
||||
@@ -189,7 +189,7 @@ async function run() {
|
||||
out.backToZero = ends.endOffsetsOf(base, ends.withEndOffset(base, both, "start", 0));
|
||||
|
||||
// ── ④ 기준표 ──
|
||||
const fields = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Fields.ts"), {});
|
||||
const fields = load(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Grade_Criteria.ts"), {});
|
||||
out.criteria = fields.PROFILE_CRITERIA;
|
||||
out.speedChoices = fields.SPEED_CHOICES;
|
||||
|
||||
|
||||
@@ -236,7 +236,11 @@ def test_부르는_곳():
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
profile = read("B05_Profile/B05_Profile_Engine_Grade_Profile.py")
|
||||
assert "base_z = straight_pass_through(base_s, base_z, policy)" in profile
|
||||
# 1순위 첫 계획선 = 서버 Node(511-B B-3 · TS `groundFollowingNodes` → `passThrough`)
|
||||
assert "ground_following={" in profile
|
||||
assert "const baseZ = passThrough(nodeS, target, input.policy);" in read(
|
||||
"B05_Profile/logic/B05_Profile_Grade_Ground.ts"
|
||||
)
|
||||
assert "straight_pass_through(base_s, target, policy, lambda z: _hold(" in read(
|
||||
"B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""PLAN 448(442-2) 거울 — 「페이지 설정」 TS 기준표 ↔ 파이썬 법정 기준 전수.
|
||||
|
||||
TS `B05_Profile_UI_Panel_Fields.ts` PROFILE_CRITERIA · SPEED_CHOICES 와 `gradeLimitPct` 규칙
|
||||
TS `logic/B05_Profile_Grade_Criteria.ts`(511-B B-3 · 화면 `_UI_Panel_Fields.ts` 에서 옮김)
|
||||
PROFILE_CRITERIA · SPEED_CHOICES 와 `gradeLimitPct` 규칙
|
||||
(고를 수 없는 속도 = 첫 선택지 · 넣은 값 > 0 이면 그 값 · 아니면 표)을 파이썬
|
||||
`FOREST_ROAD_PROFILE_CRITERIA` · `resolve_grade_options(...).max_grade_pct` 와 임도 종류 × 속도 ×
|
||||
지형 × 입력 유무 전부 대조. TS 표는 `helper_442_2_page_settings.cjs` 가 꺼냄.
|
||||
|
||||
@@ -42,6 +42,9 @@ NODE_IDS = {
|
||||
"excavation.trench",
|
||||
"corridor.mesh",
|
||||
"profile.seat",
|
||||
"profile.base",
|
||||
"profile.alignment",
|
||||
"profile.carry",
|
||||
"facility.plus_follow",
|
||||
"facility.follow",
|
||||
"cross.design",
|
||||
@@ -106,23 +109,8 @@ GEOMETRY_PULLS = (
|
||||
# 노선 바뀐 [저장] 넘김 patch(472-6 6-B) — 세션 칸 → 설계 칸 얹기가 관 벽 · 단 · 세월 · BOX 기본값(위 그림 파일)을 씀
|
||||
"B06_Section/B06_Section_Route_Carry_Patch.ts",
|
||||
)
|
||||
# 묶음 단계 입구(511-B B-1 · `design_calc_node_stages.ts`) — planline 저장 기준
|
||||
# (`plantedBase`)이 종단도 측점 표 · 관 세션 id 파일을 끎(모듈 맨 위 DOM · 세션 없음)
|
||||
# — B-3 이 logic 으로 옮기면 빈 목록.
|
||||
STAGE_PULLS = (
|
||||
"A00_Common/b_page_state.ts",
|
||||
"A00_Common/design/design_pipe_ids.ts",
|
||||
"A00_Common/design/design_pipeline_base.ts",
|
||||
"A00_Common/design/design_profile_base.ts",
|
||||
"A00_Common/design/design_selection.ts",
|
||||
"A00_Common/design/design_store.ts",
|
||||
"A00_Common/design/design_structure_status.ts",
|
||||
"B05_Profile/B05_Profile_UI_IrregularStations.ts",
|
||||
"B05_Profile/B05_Profile_UI_Profile_Layout.ts",
|
||||
"B05_Profile/B05_Profile_UI_Profile_Table.ts",
|
||||
"B06_Section/B06_Section_UI_Section_Common.ts",
|
||||
)
|
||||
KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS) | set(STAGE_PULLS)
|
||||
# 묶음 단계 입구(511-B B-1) planline 저장 기준(`plantedBase`) 끌림 11 = B-3 이 logic 으로 옮겨 걷음
|
||||
KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS)
|
||||
|
||||
|
||||
def _node(*args: str, timeout: int = 120) -> str:
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""511-B B-3 종단 짝 모으기 — 종단 다시 셈 · 첫 계획선 · 계획고 옮김 = 서버 Node(TS 한 벌).
|
||||
|
||||
옛 파이썬 길(`build_alignment` · `straight_pass_through` — 2~4 순위 폴백 몫으로 남음)로 지은
|
||||
정본과 새 Node 길(`rebuild_alignment_profile` · `design_ground_following_profile` 겉 →
|
||||
`profile.alignment` · `profile.base` → `alignment_record`)이 낸 정본의 **JSON 글이 같음**
|
||||
(칸 · 차례 · 실수형 · −0.0 · 반올림).
|
||||
사용자 R(측점 밖 곡선 시종점) · 측점 0.006 m 안 편집 키 · 법정 R 미달 경고 · 시종점 오프셋 포함.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_511_b3_profile_node.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import ( # noqa: E402
|
||||
ground_profile,
|
||||
legal_plan_radius_min_m, # noqa: E402
|
||||
resolve_grade_options,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
|
||||
AlignmentPolicy,
|
||||
build_alignment,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_edits # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_ground_following_profile,
|
||||
infer_station_interval,
|
||||
rebuild_alignment_profile,
|
||||
straight_pass_through,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
def _ground(s: float, phase: float) -> float:
|
||||
return 300.0 + 0.06 * s + 1.4 * math.sin(s / 37.0 + phase) + 0.2 * math.sin(s / 7.3)
|
||||
|
||||
|
||||
def _longitudinal(phase: float, length: float = 400.0) -> dict:
|
||||
samples = [
|
||||
{"chainage_m": float(s), "elevation_m": _ground(float(s), phase), "valid": True}
|
||||
for s in np.arange(0.0, length + 1e-9, 1.0)
|
||||
]
|
||||
marks = sorted({*np.arange(0.0, length + 1e-9, 20.0).tolist(), 133.37, 251.2, 318.06})
|
||||
stations = [
|
||||
{"station_id": f"s{index}", "chainage_m": float(value)} for index, value in enumerate(marks)
|
||||
]
|
||||
return {"samples": samples, "stations": stations, "length_m": length, "design_profiles": []}
|
||||
|
||||
|
||||
def _options():
|
||||
return resolve_grade_options("trunk", terrain_type="special", design_speed_kph=30)
|
||||
|
||||
|
||||
def _old_ground_following(longitudinal: dict, options, edits: dict | None) -> dict:
|
||||
"""옛 1순위 몸통(B-3 전 `design_ground_following_profile`) — 파이썬 꼭짓점 · 파이썬 선형."""
|
||||
chainage, ground = ground_profile(longitudinal)
|
||||
total = float(chainage[-1])
|
||||
stations = list(longitudinal["stations"])
|
||||
policy = AlignmentPolicy.from_config(
|
||||
station_interval_m=infer_station_interval(stations),
|
||||
max_grade_pct=options.max_grade_pct,
|
||||
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
|
||||
paved=options.paved,
|
||||
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
|
||||
min_vertical_radius_m=options.min_vertical_radius_m,
|
||||
min_curve_length_m=options.min_curve_length_m,
|
||||
)
|
||||
node_s = sorted(
|
||||
{round(float(row["chainage_m"]), 3) for row in stations if 0 <= row["chainage_m"] <= total}
|
||||
)
|
||||
if node_s[-1] < total:
|
||||
node_s.append(round(total, 3))
|
||||
base_s = np.array(node_s, dtype=np.float64)
|
||||
base_z = np.interp(base_s, chainage, ground)
|
||||
base_z[0] += options.start_elevation_offset_m
|
||||
base_z[-1] += options.end_elevation_offset_m
|
||||
base_z = straight_pass_through(base_s, base_z, policy)
|
||||
return build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=stations,
|
||||
policy=policy,
|
||||
edits=dict(edits or {}),
|
||||
)
|
||||
|
||||
|
||||
def _old_rebuild(longitudinal: dict, edits: dict | None) -> dict:
|
||||
"""옛 `rebuild_alignment_profile` 몸통 — 저장 꼭짓점 · 파이썬 선형."""
|
||||
stored = longitudinal["profile_alignment"]
|
||||
chainage, ground = ground_profile(longitudinal)
|
||||
base = stored["base_pvi"]
|
||||
criteria = (longitudinal.get("design_profiles") or [{}])[0].get("criteria") or {}
|
||||
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
||||
policy = replace(
|
||||
policy,
|
||||
min_vertical_radius_m=policy.min_vertical_radius_m
|
||||
or float(criteria.get("min_vertical_radius_m") or 0.0),
|
||||
min_curve_length_m=policy.min_curve_length_m
|
||||
or float(criteria.get("min_curve_length_m") or 0.0),
|
||||
)
|
||||
return build_alignment(
|
||||
base_s=np.array([float(n["chainage_m"]) for n in base], dtype=np.float64),
|
||||
base_z=np.array([float(n["elevation_m"]) for n in base], dtype=np.float64),
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=list(longitudinal["stations"]),
|
||||
policy=policy,
|
||||
edits=edits,
|
||||
)
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
EDITS = {
|
||||
"station_offsets": {"60.000": 0.8, "133.370": -0.45, "251.204": 0.3, "300.000": None},
|
||||
"curve_radii": {"100.000": 350.0, "251.200": 77.7, "60.000": 1234.5},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", [0.0, 0.7, 2.1])
|
||||
def test_첫_계획선_옛_파이썬과_정본_글_같음(phase: float) -> None:
|
||||
longitudinal = _longitudinal(phase)
|
||||
options = _options()
|
||||
old = _old_ground_following(longitudinal, options, None)
|
||||
new, profile = design_ground_following_profile(longitudinal, options)
|
||||
assert _text(new) == _text(old)
|
||||
assert profile["samples"] == new["samples"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", [0.0, 0.7, 2.1])
|
||||
def test_다시_셈_옛_파이썬과_정본_글_같음(phase: float) -> None:
|
||||
longitudinal = _longitudinal(phase)
|
||||
alignment, profile = design_ground_following_profile(longitudinal, _options())
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
longitudinal["design_profiles"] = [profile]
|
||||
for edits in (None, {}, copy.deepcopy(EDITS)):
|
||||
old = _old_rebuild(longitudinal, copy.deepcopy(edits))
|
||||
new, entry = rebuild_alignment_profile(longitudinal, copy.deepcopy(edits))
|
||||
assert _text(new) == _text(old)
|
||||
assert entry["summary"]["warnings"] == old["warnings"]
|
||||
# 사용자 R 77.7 m = 측점 밖 곡선 시종점 · 법정 R 미달 경고(432)
|
||||
assert any(abs(s["chainage_m"] * 1000 % 1) > 1e-6 for s in new["samples"])
|
||||
assert any("종단곡선" in text for text in new["warnings"])
|
||||
|
||||
|
||||
def test_시종점_오프셋_첫_계획선_같음() -> None:
|
||||
longitudinal = _longitudinal(1.3)
|
||||
options = replace(_options(), start_elevation_offset_m=0.6, end_elevation_offset_m=-0.4)
|
||||
old = _old_ground_following(longitudinal, options, EDITS)
|
||||
new, _profile = design_ground_following_profile(longitudinal, options, edits=EDITS)
|
||||
assert _text(new) == _text(old)
|
||||
|
||||
|
||||
def test_저장_선형_없으면_ValueError() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
rebuild_alignment_profile(_longitudinal(0.0), {})
|
||||
|
||||
|
||||
def test_계획고_옮김_꼭짓점_세움_한_부름() -> None:
|
||||
"""노선 [저장] 몫 — 편집 옮김(`carryEdits`)과 꼭짓점 세움(`seatRouteMoved`)이 Node 한 부름 ·
|
||||
편집만 부르는 겉(`carry_edits`)과 같은 편집."""
|
||||
old_xy = [[0.0, 0.0], [200.0, 0.0], [400.0, 0.0]]
|
||||
new_xy = [[0.0, 0.0], [200.0, 0.0], [300.0, 60.0], [420.0, 60.0]]
|
||||
longitudinal = _longitudinal(0.4)
|
||||
alignment, _profile = design_ground_following_profile(longitudinal, _options())
|
||||
base = alignment["base_pvi"]
|
||||
edits = {"station_offsets": {"60.000": 0.5, "120.000": -0.3}, "curve_radii": {"120.000": 400}}
|
||||
stations = [row["chainage_m"] for row in longitudinal["stations"]]
|
||||
seat = {
|
||||
"ground_s": [row["chainage_m"] for row in alignment["samples"]],
|
||||
"ground_z": [row["ground_elevation_m"] for row in alignment["samples"]],
|
||||
"policy": AlignmentPolicy.from_dict(alignment["policy"]).as_dict(),
|
||||
"base_pvi": base,
|
||||
"born": [251.2],
|
||||
}
|
||||
moved, seated = carry_seat(old_xy, new_xy, base, edits, stations, 10.0, seat)
|
||||
assert moved == carry_edits(old_xy, new_xy, base, edits, stations, 10.0)
|
||||
assert set(moved["station_offsets"]) == {"60.000", "120.000"}
|
||||
assert moved["curve_radii"] == {"120.000": 400.0}
|
||||
assert all(isinstance(v, float) for v in moved["station_offsets"].values())
|
||||
assert any(abs(node["chainage_m"] - 251.2) < 1e-6 for node in seated)
|
||||
assert carry_edits(old_xy, new_xy, [], edits, stations, 10.0) == {
|
||||
"station_offsets": {},
|
||||
"curve_radii": {},
|
||||
}
|
||||
@@ -78,7 +78,10 @@ def test_source_guard_sits_on_the_single_apply_path():
|
||||
"""
|
||||
# 강제 여부 = 페이지 설정 한 칸을 그 자리에서 읽음(472-5 · 보기마다 사본 없음)
|
||||
assert "const enforce = enforceMinCoverOf(projectId);" in PANEL
|
||||
assert "if (blocksMinCover(base, alignment, candidate, enforce, minCoverTargets)) return false;" in PANEL
|
||||
assert (
|
||||
"if (blocksMinCover(base, alignment, candidate, enforce, minCoverTargets)) return false;"
|
||||
in PANEL
|
||||
)
|
||||
assert PANEL.count("blocksMinCover(") == 1 # 부르는 자리는 한 곳뿐.
|
||||
assert "export function blocksMinCover(" in GUARD
|
||||
assert "blocksMinCover" not in RENDER # 화면 모듈에 사본이 남지 않았다.
|
||||
@@ -96,9 +99,11 @@ def test_source_old_diameter_formula_removed():
|
||||
assert "minCoverTargets: () => minCoverTargets" in PANEL
|
||||
|
||||
|
||||
ALIGNMENT = (
|
||||
PROJECT_ROOT / "B05_Profile" / "logic" / "B05_Profile_UI_Profile_Alignment.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
# 구간 쉬프트 · R 입력 = `_Alignment_Edit.ts`(700줄 나눔 · 511-B B-3) — 두 파일을 한 글로 봄
|
||||
ALIGNMENT = "".join(
|
||||
(PROJECT_ROOT / "B05_Profile" / "logic" / name).read_text(encoding="utf-8")
|
||||
for name in ("B05_Profile_UI_Profile_Alignment.ts", "B05_Profile_UI_Profile_Alignment_Edit.ts")
|
||||
)
|
||||
|
||||
|
||||
def test_shift_moves_hinges_not_fixed_ends():
|
||||
|
||||
Reference in New Issue
Block a user