diff --git a/A00_Common/design/design_pipeline_base.ts b/A00_Common/design/design_pipeline_base.ts index 0a67c0a40..2491b96d7 100644 --- a/A00_Common/design/design_pipeline_base.ts +++ b/A00_Common/design/design_pipeline_base.ts @@ -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, types: ReadonlyArray, - /** 관 세션 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[1]; - const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관 - return listedBase(toAlignmentBase(stored), detail, planted); + return plantedBaseWith(source, inputs, types, (pipes) => withPipeIds(pipes)); } diff --git a/A00_Common/design/design_profile_base.ts b/A00_Common/design/design_profile_base.ts index 4bf4bcfa7..9a11d5618 100644 --- a/A00_Common/design/design_profile_base.ts +++ b/A00_Common/design/design_profile_base.ts @@ -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"; diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts index 67b16f8a7..28937013d 100644 --- a/A00_Common/design/design_structure_status.ts +++ b/A00_Common/design/design_structure_status.ts @@ -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, - 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` 가 그 카드만 고침 — 카드 줌 · 팬 지킴). diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index ca8f19746..45fa11fc8 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -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[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 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) diff --git a/A00_Common/design/logic/design_calc_node_profile.ts b/A00_Common/design/logic/design_calc_node_profile.ts new file mode 100644 index 000000000..834190246 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_profile.ts @@ -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 | 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 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) }; + }, + }; +} diff --git a/A00_Common/design/logic/design_calc_node_stages.ts b/A00_Common/design/logic/design_calc_node_stages.ts index 235cd3c30..2dfb25761 100644 --- a/A00_Common/design/logic/design_calc_node_stages.ts +++ b/A00_Common/design/logic/design_calc_node_stages.ts @@ -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), }), diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index 79b0e5d37..e07f67188 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -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)", }, /* ── 횡단 ─────────────────────────────────────────────────────────────── */ { diff --git a/A00_Common/design/logic/design_planted_base.ts b/A00_Common/design/logic/design_planted_base.ts new file mode 100644 index 000000000..e721c6033 --- /dev/null +++ b/A00_Common/design/logic/design_planted_base.ts @@ -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, + 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, + types: ReadonlyArray, + 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[1]; + const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관 + return listedBase(toAlignmentBase(stored), detail, planted); +} diff --git a/B05_Profile/B05_Profile_Engine_Grade_Carry.py b/B05_Profile/B05_Profile_Engine_Grade_Carry.py index 56c5d8784..9d0690dd5 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Carry.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Carry.py @@ -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( diff --git a/B05_Profile/B05_Profile_Engine_Grade_Node.py b/B05_Profile/B05_Profile_Engine_Grade_Node.py new file mode 100644 index 000000000..58d057803 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_Grade_Node.py @@ -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"] diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 0c3ff91fe..ff6b4f6a6 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -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), diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 388f627ff..acda04356 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -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 diff --git a/B05_Profile/B05_Profile_UI_IrregularStations.ts b/B05_Profile/B05_Profile_UI_IrregularStations.ts index a425b540d..1286d9d34 100644 --- a/B05_Profile/B05_Profile_UI_IrregularStations.ts +++ b/B05_Profile/B05_Profile_UI_IrregularStations.ts @@ -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"; diff --git a/B05_Profile/B05_Profile_UI_Panel_Fields.ts b/B05_Profile/B05_Profile_UI_Panel_Fields.ts index 9fabc1037..066ca8a4a 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Fields.ts @@ -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; 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 = { trunk: "간선임도", @@ -78,14 +46,6 @@ export const ROAD_TYPE_LABELS: Record = { branch: "작업임도(구 지선)", }; -/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */ -export const SPEED_CHOICES: Record> = { - 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 { diff --git a/B05_Profile/B05_Profile_UI_Profile_Layout.ts b/B05_Profile/B05_Profile_UI_Profile_Layout.ts index f5cdbfcd3..c372280a5 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Layout.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Layout.ts @@ -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, diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 13880cf22..ffa7410d0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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 { diff --git a/B05_Profile/logic/B05_Profile_Grade_Criteria.ts b/B05_Profile/logic/B05_Profile_Grade_Criteria.ts new file mode 100644 index 000000000..77de876da --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Grade_Criteria.ts @@ -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; 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> = { + trunk: [20, 30, 40], + fire: [20, 30, 40], + work: [20], + branch: [20], +}; diff --git a/B05_Profile/logic/B05_Profile_Grade_Ground.ts b/B05_Profile/logic/B05_Profile_Grade_Ground.ts index 01e724e0a..f5c73f0c6 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Ground.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Ground.ts @@ -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, +): 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 + ); } diff --git a/B05_Profile/logic/B05_Profile_Profile_Planted.ts b/B05_Profile/logic/B05_Profile_Profile_Planted.ts new file mode 100644 index 000000000..4e34ffc1f --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Profile_Planted.ts @@ -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] }, + })); +} diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index c26217af1..e7d6f1919 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -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(base.chainage.map((value) => Number(value.toFixed(3)))); + // 격자 자리 = 0.000001 m(파이썬 `np.round(merged, 6)` 와 같은 값 — 서버 Node 정본 · 511-B B-3). + const sampleChainages = new Set(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; diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts new file mode 100644 index 000000000..5877c0d9c --- /dev/null +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts @@ -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 }; +} diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index da8b2b5a7..8b8cace41 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -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", diff --git a/resources/tester/helper_271_profile_settings.cjs b/resources/tester/helper_271_profile_settings.cjs index 53cdfe39b..74264b478 100644 --- a/resources/tester/helper_271_profile_settings.cjs +++ b/resources/tester/helper_271_profile_settings.cjs @@ -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 = () => diff --git a/resources/tester/helper_442_2_page_settings.cjs b/resources/tester/helper_442_2_page_settings.cjs index d816d89e1..d6ea93bf6 100644 --- a/resources/tester/helper_442_2_page_settings.cjs +++ b/resources/tester/helper_442_2_page_settings.cjs @@ -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; diff --git a/resources/tester/test_438_1_initial_straighten.py b/resources/tester/test_438_1_initial_straighten.py index c142b4cfa..8d5690c81 100644 --- a/resources/tester/test_438_1_initial_straighten.py +++ b/resources/tester/test_438_1_initial_straighten.py @@ -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" ) diff --git a/resources/tester/test_442_2_grade_limit_mirror.py b/resources/tester/test_442_2_grade_limit_mirror.py index 9dce7a895..74e9bfaf9 100644 --- a/resources/tester/test_442_2_grade_limit_mirror.py +++ b/resources/tester/test_442_2_grade_limit_mirror.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` 가 꺼냄. diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index c1e3067ba..a0a74e420 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -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: diff --git a/resources/tester/test_511_b3_profile_node.py b/resources/tester/test_511_b3_profile_node.py new file mode 100644 index 000000000..630b272f4 --- /dev/null +++ b/resources/tester/test_511_b3_profile_node.py @@ -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": {}, + } diff --git a/resources/tester/test_b05_min_cover_guard.py b/resources/tester/test_b05_min_cover_guard.py index d594affeb..786eefdb3 100644 --- a/resources/tester/test_b05_min_cover_guard.py +++ b/resources/tester/test_b05_min_cover_guard.py @@ -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():