diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index 667f504b8..4382d9be0 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -6,7 +6,9 @@ * 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id). 초안 자리로 뜬 시설 측점 = 등록 열쇠(주인 id)로 짝(457). * * 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 · - * 암 경계 · 암 경사 · 소단) + 사면 쉬프트(노견 확장) · 소단 깊이(417 · 서버 `ROUTE_CARRY_KEYS`) — 사용자 칸만. + * 암 경계 · 암 경사 · 소단) + 사면 쉬프트(노견 확장) · 소단 깊이(417 · 서버 `ROUTE_CARRY_KEYS`) + 손 상단측(단면 + * 유형 · 측구 방향) — 사용자 칸만. 저장분의 자리 칸(`SEAT_KEYS` · 암 경계 · 2단 경사 · 손 상단측)은 저장 측점 자리를 + * 함께 실어(`seat`) 새 자리로 간 측점에서 뺌(`crossAtSeat` · 요구 275-3) · 세션 고침은 끌기 넘김이 이미 가름. * 그림 자료 · 카드 이름 · 얹힌 시설 맥락(포장 · 기울임 · 하강) · 단면 유형 · 측구 방향은 새 자리에서 다시 셈 * (`logic/B05_Profile_Pipeline_Detail.ts` `crossWithShapes` · `draftSectionDetail` · `crossWithUphill` · 457). 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · * 지반 유형 · 측구 · 2단 경사 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에 @@ -20,8 +22,14 @@ import { effectiveStandardCross, readRockBoundaryDefault, } from "../../B06_Section/B06_Section_UI_Standard_Panel"; -import type { CrossInput, SectionSpecs } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +import type { + CrossInput, + SeatedFields, + SectionSpecs, +} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +import { SEAT_HINT } from "../../B06_Section/logic/B06_Section_Route_Carry"; import type { PipelineStation } from "./logic/design_pipeline_types"; +import type { StationSeat } from "../../common_util/common_util_station_key"; export interface PipelineCrossSource { input: CrossInput; @@ -47,6 +55,15 @@ export const forceCross = (): void => void (forced += 1); const finite = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; +/** 저장분 자리 칸의 입력 이름(`SEAT_KEYS` 짝 · 요구 275-3). */ +const SEAT_INPUTS = [ + "twoStageSlope", + "rockBoundaryOffsetM", + "cutSlopeRatio", + "sectionMode", + "ditchSide", +] as const; + /** 측점 하나의 넘기는 사용자 입력(cross 단계 `CrossStationInput` 꼴 · 시설 맥락은 새 자리 그림에서 · 457). */ function carried(section: CrossSection, saved?: CrossSection["design"]): Record { const design = (saved ?? section.design ?? {}) as unknown as Record; @@ -62,6 +79,8 @@ function carried(section: CrossSection, saved?: CrossSection["design"]): Record< twoStageSlope: Boolean(design.two_stage_slope), rockBoundaryOffsetM: finite(design.rock_boundary_offset_m), cutSlopeRatio: cutSlope !== null && cutSlope > 0 ? cutSlope : null, + ...(typeof design.section_mode === "string" ? { sectionMode: design.section_mode } : {}), + ...(typeof design.ditch_side === "string" ? { ditchSide: design.ditch_side } : {}), berm: berm ? { widthM: berm.width_m, @@ -89,21 +108,29 @@ function sides(design: Record, field: string, name: string) { return out; } -/** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */ +/** 세션 칸 하나를 측점 열쇠마다 [열쇠, 값, 쓴 때 자리] — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */ function sessionCells( name: "crossdesign" | "rockb" | "cutslope" | "bermdepth" | "shoulderext", projectId: string, routeId: number, -): Array<[string, unknown]> { +): Array<[string, unknown, StationSeat | undefined]> { const raw = readState>(name, projectId, routeId); if (!raw || typeof raw !== "object") return []; - return Object.entries(raw).filter(([key]) => key.includes(":") && !key.startsWith("at:")); + const seats = (raw[SEAT_HINT] ?? {}) as Record; + return Object.entries(raw) + .filter(([key]) => key.includes(":") && !key.startsWith("at:")) + .map(([key, value]) => [key, value, seats[key]]); } -/** 세션 횡단 고침 → 측점 열쇠마다 넘기는 입력 조각(`carried` 와 같은 칸 이름). */ +/** 이 자리에서 온 자리 칸 이름을 입력 조각에 붙임(`crossAtSeat` 가 새 자리면 뺌 · 요구 275-3). */ +function seatOn(entry: Record, seat: StationSeat | undefined, fields: string[]) { + if (seat && fields.length) ((entry.seats ??= []) as SeatedFields[]).push({ ...seat, fields }); +} + +/** 세션 횡단 고침 → 측점 열쇠마다 넘기는 입력 조각(`carried` 와 같은 칸 이름 · 자리 칸은 쓴 때 자리째). */ function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] { const out: CrossInput["byKey"] = {}; - for (const [key, value] of sessionCells("crossdesign", projectId, routeId)) { + for (const [key, value, seat] of sessionCells("crossdesign", projectId, routeId)) { const choice = (value ?? {}) as Record; const entry = (out[key] ??= {}); if (typeof choice.ground_type === "string") entry.groundType = choice.ground_type; @@ -111,12 +138,26 @@ function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] if ("ditch_choice" in choice) entry.ditchChoice = typeof choice.ditch_choice === "boolean" ? choice.ditch_choice : null; if ("two_stage_slope" in choice) entry.twoStageSlope = Boolean(choice.two_stage_slope); + if (typeof choice.section_mode === "string") entry.sectionMode = choice.section_mode; + if (typeof choice.ditch_side === "string") entry.ditchSide = choice.ditch_side; + seatOn( + entry, + seat, + ["twoStageSlope", "sectionMode", "ditchSide"].filter((name) => name in entry), + ); } - for (const [key, value] of sessionCells("rockb", projectId, routeId)) - if (finite(value) !== null) (out[key] ??= {}).rockBoundaryOffsetM = value; - for (const [key, value] of sessionCells("cutslope", projectId, routeId)) { + for (const [key, value, seat] of sessionCells("rockb", projectId, routeId)) + if (finite(value) !== null) { + const entry = (out[key] ??= {}); + entry.rockBoundaryOffsetM = value; + seatOn(entry, seat, ["rockBoundaryOffsetM"]); + } + for (const [key, value, seat] of sessionCells("cutslope", projectId, routeId)) { const ratio = finite(value); - if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null; + if (ratio === null) continue; + const entry = (out[key] ??= {}); + entry.cutSlopeRatio = ratio > 0 ? ratio : null; + seatOn(entry, seat, ["cutSlopeRatio"]); } // 소단 쪽 깊이(194-1) — 소단 제원은 구조물 구간이 정함(`crossWithBerm` · 365-5) · 사면 쉬프트(139-6 · 417 M3) for (const [state, name] of [ @@ -147,12 +188,20 @@ export function pipelineCrossSource( if (!detail || !standard) return null; const byKey: CrossInput["byKey"] = {}; const specs = new Map(); + /** 저장분을 실은 측점 열쇠 → 저장 측점 자리(자리 칸 판정 · 요구 275-3). */ + const seats = new Map(); // 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 365-5) const stored = routeId === null ? null : savedDesigns(projectId, routeId); // 세션 다시 안 풂(387-7) const storedAt = (chainageM: number) => stored?.find((row) => Math.abs(row.chainage_m - chainageM) < SAME_M)?.design; - const put = (key: string, section: CrossSection, at = section.chainage_m): void => { + const put = ( + key: string, + section: CrossSection, + owner: PipelineStation, + at = section.chainage_m, + ): void => { byKey[key] = carried(section, storedAt(at)); + seats.set(key, { x: owner.x, y: owner.y, left: owner.left }); // 저장 측점 자리 // 카드 번호 = 저장 카드 번호(같은 측점 열쇠 · 옮긴 관도 · 찾기 · 고름이 같은 id · 440) specs.set(key, { station_id: section.station_id }); }; @@ -164,7 +213,7 @@ export function pipelineCrossSource( if (gap < SAME_M && (!best || gap < Math.abs(best.chainageM - section.chainage_m))) best = station; } - if (best && !byKey[best.key]) put(best.key, section); + if (best && !byKey[best.key]) put(best.key, section, best); else if (!best && section.kind === "irregular") strays.push(section); } // 초안 자리로 미리 뜬 관 · 구조물 측점(초안 둔 채 열기 — 상세가 초안 누가거리) = 등록 열쇠(주인 id)가 같은 저장 @@ -178,11 +227,19 @@ export function pipelineCrossSource( const owner = savedStations.find( (station) => station.key === key && (station.kind === "pipe" || station.kind === "structure"), ); - if (owner && !byKey[key]) put(key, section, owner.chainageM); + if (owner && !byKey[key]) put(key, section, owner, owner.chainageM); + } + const session = routeId === null ? {} : sessionCarried(projectId, routeId); + for (const [key, entry] of Object.entries(session)) byKey[key] = { ...byKey[key], ...entry }; + // 저장분에서 온 자리 칸 = 저장 측점 자리째(세션이 고친 칸은 세션 값과 그 자리) + for (const [key, seat] of seats) { + const own = session[key] ?? {}; + seatOn( + byKey[key], + seat, + SEAT_INPUTS.filter((name) => byKey[key][name] !== undefined && !(name in own)), + ); } - if (routeId !== null) - for (const [key, entry] of Object.entries(sessionCarried(projectId, routeId))) - byKey[key] = { ...byKey[key], ...entry }; return { // 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p) // 카드 번호 지문(365-5 · 시설 그림이 바뀌면 관 · 구조물 입력이 cross 를 다시 돌림 · 457) diff --git a/A00_Common/design/design_pipeline_probe.ts b/A00_Common/design/design_pipeline_probe.ts index 06f3d1f48..daa0da8cf 100644 --- a/A00_Common/design/design_pipeline_probe.ts +++ b/A00_Common/design/design_pipeline_probe.ts @@ -20,6 +20,7 @@ import { groundStageDeps } from "../../B05_Profile/B05_Profile_Api_TerrainGrid"; import { createGroundStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround"; import { crossWithShapes, + crossAtSeat, crossWithUphill, shapeStatus, type CrossInput, @@ -81,7 +82,11 @@ export async function pipelineCrossAtChainage( inputs.structures, await fetchStructureTypes(true).catch(() => []), ); - const shaped = crossWithShapes(inputs.cross as CrossInput | null, [station], status); + const shaped = crossWithShapes( + crossAtSeat(inputs.cross as CrossInput | null, [station]), + [station], + status, + ); const out = { profile, ground: { stations: [station], longitudinal: { chainageM: [], z: [] } }, diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 9053f0b7b..ebf385d78 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -49,6 +49,7 @@ from B05_Profile.B05_Profile_Route_Save_Parts import ( route_moved_stations, seat_route_moved, station_keys, + station_seats, vertices_of, ) from B05_Profile.B05_Profile_Schema import RouteReplanRequest, RouteVertexInput @@ -277,6 +278,7 @@ async def _carry_cross( from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages from B06_Section.B06_Section_Router_Design import ( CARRY_INPUT_KEYS, + SEAT_KEYS, STRUCTURE_ADJUST_KEYS, compute_default_designs, ) @@ -292,8 +294,19 @@ async def _carry_cross( for row in old_designs if isinstance(row.get("design"), dict) } + # 자리 칸(암 경계 · 2단 경사 · 손 상단측)은 같은 자리 측점만(요구 275-3) + seats = ( + station_seats(old_long.get("stations") or [], old_keys), + station_seats(new_long.get("stations") or [], new_keys), + ) inputs, adjusts, dropped = carry_cross_inputs( - old_keys, new_keys, designs, (*CARRY_INPUT_KEYS, *ROUTE_CARRY_KEYS), STRUCTURE_ADJUST_KEYS + old_keys, + new_keys, + designs, + (*CARRY_INPUT_KEYS, *ROUTE_CARRY_KEYS), + STRUCTURE_ADJUST_KEYS, + SEAT_KEYS, + seats, ) chainages = await asyncio.to_thread(_rowless_station_chainages, project_root, relative, []) entries = await asyncio.to_thread( diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index ab33ac882..6ba3030ab 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -32,7 +32,12 @@ from common_util.common_util_drainage_pipes import ( ) from common_util.common_util_route_geometry import RouteVertex from common_util.common_util_station_key import chainage_key as station_chainage_key -from common_util.common_util_station_key import curve_moves, station_key +from common_util.common_util_station_key import ( + curve_moves, + same_seat, + station_key, + station_seat, +) from common_util.common_util_structure_station import plan_stations, snap_structure_station from config.config_system import DRAINAGE_PIPE_MIN_SPACING_M @@ -153,20 +158,36 @@ def station_keys( return out +def station_seats( + stations: Sequence[dict[str, Any]], keys: dict[str, float] +) -> dict[str, tuple[float, float, tuple[float, float] | None] | None]: + """측점 열쇠 → 그 측점 자리(`station_seat` · 같은 누가거리 0.001 m 줄 · 모르면 None).""" + rows = { + round(float(row["chainage_m"]), 3): row + for row in stations + if isinstance(row, dict) and row.get("chainage_m") is not None + } + return {key: station_seat(rows.get(round(at, 3))) for key, at in keys.items()} + + def carry_cross_inputs( old_keys: dict[str, float], new_keys: dict[str, float], old_designs: dict[float, dict[str, Any]], input_keys: Sequence[str], adjust_keys: Sequence[str], + seat_keys: Sequence[str] = (), + seats: tuple[dict[str, Any], dict[str, Any]] | None = None, ) -> tuple[dict[float, dict[str, Any]], dict[float, dict[str, Any]], int]: """같은 열쇠 측점으로 횡단 입력을 넘김 → (입력{새 누가거리 0.001: 값}, 구조물 조정{…}, 버린 수). `old_designs` = 옛 노선 행 {누가거리 0.001: design}. 버린 수 = 설계가 있던 옛 측점 중 새 노선에 같은 열쇠가 없는 것(「노선 밖 고침」) · MC 가 움직인 곡선 열쇠는 가까운 새 곡선 측점 - (`curve_moves`). + (`curve_moves`). 자리 칸(`seat_keys` · 요구 275-3)은 옛 · 새 자리(`seats` = 열쇠 → 자리)가 + 같은(`same_seat`) 측점만 넘김 · 새 자리 = 기본값(TS `carryKeyedStates` 와 같은 규칙). """ moves = curve_moves(list(old_keys.items()), list(new_keys.items())) + old_seats, new_seats = seats or ({}, {}) inputs: dict[float, dict[str, Any]] = {} adjusts: dict[float, dict[str, Any]] = {} dropped = 0 @@ -174,11 +195,15 @@ def carry_cross_inputs( design = old_designs.get(round(old_at, 3)) if not isinstance(design, dict): continue - new_at = new_keys.get(moves.get(key, key)) + new_key = moves.get(key, key) + new_at = new_keys.get(new_key) if new_at is None: dropped += 1 continue - carried = {name: design[name] for name in input_keys if design.get(name) is not None} + seated = same_seat(old_seats.get(key), new_seats.get(new_key)) + names = [n for n in input_keys if seated or n not in seat_keys] + names += [n for n in seat_keys if seated and n not in names] + carried = {name: design[name] for name in names if design.get(name) is not None} if carried: inputs[round(new_at, 3)] = carried if key.startswith(("pipe:", "structure:")): diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index 6f8f63de9..b7ed1724d 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -9,6 +9,8 @@ * crossWithUphill cross 단계 입력에 단면 유형 기본값(상단측)을 얹음 — 서버 노선 저장이 단면 유형 · * 측구 방향을 새 지형으로 정하는 것과 같은 규칙(넘기는 칸 = 부르는 쪽 입력). * crossWithShapes 얹힌 시설 맥락(포장 · 기울임 · 하강) = 지금 현황을 새 측점에 다시 얹은 그림에서(457). + * crossAtSeat 자리 칸(암 경계 · 2단 경사 · 손 상단측 · `seats`)은 측점이 그 자리 그대로일 때만 · 새 자리 = + * 기본값(요구 275-3 · 판정 `sameSeat` · 서버 노선 저장 · Node 넘김과 같은 규칙). * draftSectionDetail ground · profile · cross → 상세(측점 · 지반 · 횡단 설계 · 그림 자료 · 카드 이름 = 지금 * 현황을 새 측점에 다시 얹음 · 캐시 `withDraftShapes` 와 같은 `attachShapes` · 457). * @@ -28,6 +30,7 @@ import type { import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; import { toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { stationLabel } from "../../common_util/common_util_station"; +import { sameSeat, type StationSeat } from "../../common_util/common_util_station_key"; import { attachShapes, type AttachType } from "../../common_util/common_util_structure_attach"; import { registryDefaults } from "../../common_util/common_util_culvert_sets"; import { @@ -74,6 +77,27 @@ export function uphillSide(station: PipelineStation): "left" | "right" | null { return gap > 0 ? "left" : "right"; } +/** 입력 조각의 자리 칸 묶음 — 이 자리(저장 측점 · 세션 쓴 때)에서 온 입력 이름. */ +export type SeatedFields = StationSeat & { fields: string[] }; + +/** 자리 칸을 측점 자리로 가름 — 묶음(`seats`)의 자리와 지금 측점 자리가 다르면 그 입력을 뺌(기본값) · 묶음은 걷음. */ +export function crossAtSeat( + input: CrossInput | null, + stations: ReadonlyArray, +): CrossInput | null { + if (!input) return null; + const byKey: CrossInput["byKey"] = { ...input.byKey }; + for (const station of stations) { + const { seats, ...rest } = byKey[station.key] ?? {}; + if (!Array.isArray(seats)) continue; + const here = { x: station.x, y: station.y, left: station.left }; + for (const seat of seats as SeatedFields[]) + if (!sameSeat(seat, here)) for (const name of seat.fields) delete rest[name]; + byKey[station.key] = rest; + } + return { ...input, byKey }; +} + /** 측점마다 단면 유형 기본값(상단측 깎기 · 모르면 좌) — 입력이 정한 단면 유형은 그대로. */ export function crossWithUphill( input: CrossInput | null, @@ -274,7 +298,7 @@ export const crossWithContext = ( bermAt: BermAt | null, ): CrossInput | null => crossWithBerm( - crossWithUphill(crossWithShapes(input, stations, status), stations), + crossWithUphill(crossWithShapes(crossAtSeat(input, stations), stations, status), stations), stations, bermAt, ); diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 08b3d819b..44500ae1a 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -115,16 +115,20 @@ STRUCTURE_ADJUST_KEYS = ( # 조작값은 넘기지 않는다. 받는 쪽은 `compute_default_designs(carried=…)`. # 칸 목록 정본 = TS `ROW_CARRY_KEYS`(logic/B06_Section_Station_Swap.ts) — 그 글을 읽음(472-4 ① X6). _SWAP_TS = Path(__file__).resolve().parent / "logic" / "B06_Section_Station_Swap.ts" -CARRY_INPUT_KEYS: tuple[str, ...] = tuple( - re.findall( - r'"([a-z_0-9]+)"', - re.search( - r"export const ROW_CARRY_KEYS = \[(.*?)\] as const;", - _SWAP_TS.read_text(encoding="utf-8"), - re.S, - ).group(1), + + +def _ts_keys(name: str) -> tuple[str, ...]: + """TS 목록 글(`export const NAME = [ "…" ] as const;`)의 칸 이름.""" + body = re.search( + rf"export const {name} = \[(.*?)\] as const;", _SWAP_TS.read_text(encoding="utf-8"), re.S ) -) + return tuple(re.findall(r'"([a-z_0-9]+)"', body.group(1))) + + +CARRY_INPUT_KEYS: tuple[str, ...] = _ts_keys("ROW_CARRY_KEYS") +# 자리 칸(요구 275-3 · 암 경계 · 2단 경사 · 손 상단측) — 노선이 바뀌면 같은 자리 측점만 넘김 · +# 정본 = TS `SEAT_KEYS`(같은 파일). +SEAT_KEYS: tuple[str, ...] = _ts_keys("SEAT_KEYS") def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> dict[str, Any] | None: @@ -580,12 +584,15 @@ def compute_default_designs( samples, design_elevation_from_longitudinal(longitudinal, chainage_m), ground_type=str(user.get("ground_type") or "ripping_rock"), - section_mode=default_modes.get( + # 손 상단측(같은 자리로 넘긴 단면 유형 · 측구 방향 · 요구 275-3) · 없으면 상단측 + section_mode=user.get("section_mode") + or default_modes.get( round(chainage_m, 3), f"{cross['uphill_side']}_cut" if cross.get("uphill_side") in ("left", "right") else "left_cut", ), + ditch_side=user.get("ditch_side"), ditch_type=str(user.get("ditch_type") or "standard"), # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). paved=paved_at(chainage_m, paved_ranges), diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index e976067f2..a65405da1 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -267,7 +267,11 @@ export async function swapSectionStations( for (const section of swap.detail.cross_sections) if (section.design) section.design = { ...section.design }; // 새 목록 열쇠 = 묶음 측점이 정한 것 그대로(규칙 한 벌 · 다시 셈하지 않음) - const nextKeys = stations.map((station) => ({ key: station.key, chainageM: station.chainageM })); + const nextKeys = stations.map((station) => ({ + key: station.key, + chainageM: station.chainageM, + seat: { x: station.x, y: station.y, left: station.left }, // 자리 칸 넘김 판정(요구 275-3) + })); if (swap.moved) carryStationKeys(projectId, routeId, nextKeys); else useStationKeyList(projectId, routeId, nextKeys); // 메모리만 — 세션 상세 = 저장 정본만(387-7 · 새로고침 · B06 바로 열기 = 묶음이 다시 갈아끼움 · 365-1) diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index 29f550e10..532cef832 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -10,9 +10,9 @@ * 장부 · 구조물 id 는 측점 계획(초안) · 구조물 목록(횡단 보기)에서. * · 칸마다 열쇠 → 쓴 때 누가거리 `@at` 을 함께 담음 — 구조물 id 를 아직 모를 때(첫 열기)도 그 자리 측점이면 읽음. * · 옛 초안(누가거리 열쇠 `"85.05"`) · `at:` 열쇠는 읽을 때 지금 측점 열쇠로 옮겨 다시 씀. - * · 노선이 바뀜 = `carryStationDrafts` — 넘기는 칸 = 서버 `CARRY_INPUT_KEYS` 일곱(지반 유형 · 측구 종류 · - * 측구 선택 · 2단 경사 · 암 경계 · 암 경사 · 소단 구간은 구간 그대로) + 사면 쉬프트 · 소단 깊이(417 · 서버 - * `ROUTE_CARRY_KEYS`) · 단면 유형 · 측구 방향은 새 지형이 정함 · 사라진 측점의 고침은 버리고 「노선 밖 고침 N 건」. + * · 노선이 바뀜 = `carryStationDrafts` — 넘기는 칸 = 지반 유형 · 측구 종류 · 측구 선택 · 사면 쉬프트 · 소단 깊이 + * (417 · 서버 `CARRY_INPUT_KEYS` · `ROUTE_CARRY_KEYS`) · 자리 칸(암 경계 · 암 경사 · 2단 경사 · 손 상단측)은 같은 + * 자리 측점만(값마다 쓴 때 자리 `@seat` · 요구 275-3) · 사라진 측점의 고침은 버리고 「노선 밖 고침 N 건」. * · 노선 저장 = `carryChainageStates` — 누가거리 열쇠 칸(옹벽 · 세월 · BOX · 집수 조절 · 측점 폭 · 메모 · 상세 표 · * `ROUTE_CARRY_STATES`)도 측점 열쇠로 새 노선 측점에 옮김(구조물 조절 = 관 · 구조물 열쇠만 · 서버와 같음). * ========================================================================== */ @@ -36,6 +36,7 @@ import { nearestKeyed, rawEntries, ROUTE_CARRY_STATES, + SEAT_HINT, stationKeysFrom, type CarryFacilities, type Keyed, @@ -221,27 +222,31 @@ export function writeKeyed( only?: ReadonlyArray, ): void { const raw = readRaw(name, projectId, routeId); - const next: Raw = { [HINT]: {} }; + const next: Raw = { [HINT]: {}, [SEAT_HINT]: {} }; const replaced = new Set((only ?? [...byChainage.keys()]).map((at) => chainageKey(Number(at)))); - for (const [key, value, hint] of rawEntries(raw)) { + for (const [key, value, hint, seat] of rawEntries(raw)) { const at = chainageOfKey(projectId, routeId, key, hint); if (at !== null && (only === undefined || replaced.has(chainageKey(at)))) continue; next[key] = value; if (hint !== undefined) next[HINT]![key] = hint; + if (seat) next[SEAT_HINT]![key] = seat; } + const list = lists.get(scopeOf(projectId, routeId)); for (const [at, value] of byChainage) { if (only !== undefined && !replaced.has(chainageKey(Number(at)))) continue; const key = keyAt(projectId, routeId, Number(at)); next[key] = value; next[HINT]![key] = Number(chainageKey(Number(at))); + // 쓴 때 자리 — 노선이 바뀌면 자리 칸은 이 자리 그대로인 측점만 따라감(요구 275-3) + const seat = list && nearestKeyed(list, Number(at))?.seat; + if (seat) next[SEAT_HINT]![key] = seat; } writeState(name, next, projectId, routeId); } /** * 노선이 바뀜(새 측점 목록을 `useStationKeys` 로 세운 뒤) — 옛 노선 고침을 측점 열쇠로 새 노선에 넘김. - * 넘김 = 카드 선택 넷(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사) · 암 경계 · 암 경사 · 단면 유형 · 측구 - * 방향은 새 지형이 정함 · 노견 확장 · 소단 깊이는 안 넘김. 돌려주는 값 = 노선 밖으로 나간 고침 수(측점 단위). + * 넘김 규칙 = `carryKeyedStates`(자리 칸은 같은 자리만 · 요구 275-3). 돌려주는 값 = 노선 밖으로 나간 고침 수(측점 단위). * `quiet` = 알림 없이 수만 돌려줌(부르는 쪽이 결과 한 줄에 합칠 때). */ export function carryStationDrafts( diff --git a/B06_Section/logic/B06_Section_Route_Carry.ts b/B06_Section/logic/B06_Section_Route_Carry.ts index 9b72ead2e..8058832d7 100644 --- a/B06_Section/logic/B06_Section_Route_Carry.ts +++ b/B06_Section/logic/B06_Section_Route_Carry.ts @@ -4,8 +4,9 @@ * * · 측점 열쇠 목록 = `stationKeysFrom`(일반 grid:k · 관 pipe: · 구조물 structure: · 시종점 end:BP/EP · * id 모름 at:<0.01 m> · 규칙 = `common_util_station_key`). - * · 측점 열쇠 칸 다섯 = `carryKeyedStates` — 넘김 = 카드 선택 넷 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이 · - * 단면 유형 · 측구 방향은 새 지형이 정함 · 사라진 측점 고침은 버림(수만 셈). + * · 측점 열쇠 칸 다섯 = `carryKeyedStates` — 넘김 = 카드 선택(지반 유형 · 측구 종류 · 측구 선택) · 사면 쉬프트 · + * 소단 깊이 · 자리 칸(암 경계 · 암 경사 · 2단 경사 · 단면 유형 · 측구 방향 · `SEAT_KEYS`)은 같은 자리 + * (`sameSeat` · 열쇠마다 옛 · 새 자리)만 · 새 자리 = 기본값(요구 275-3) · 사라진 측점 고침은 버림(수만 셈). * · 누가거리 열쇠 칸(`ROUTE_CARRY_STATES`) = `carryChainageStates` — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리 · * 구조물 조절 = 관 · 구조물 열쇠만. * 쓰는 곳 = 브라우저 세션(`B06_Section_Station_Keys` · 끌기 동안 같은 노선 안 목록 갈림) · 서버 Node 입구 @@ -18,8 +19,10 @@ import { curveMoves, dedupeStationKeys, looseChainage, + sameSeat, stationKey, type StationKeyRole, + type StationSeat, } from "../../common_util/common_util_station_key"; import type { CrossSection } from "../B06_Section_Api_Fetch"; @@ -33,10 +36,14 @@ export const KEYED_STATES: readonly KeyedState[] = [ "cutslope", ]; -/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */ -const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const; +/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 = rockb · cutslope · 소단 구간). */ +const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice"] as const; +/** 카드 선택 중 자리 칸(`SEAT_KEYS` · 같은 자리만 · 요구 275-3) — 2단 경사 · 손 상단측(단면 유형 · 측구 방향). */ +const SEAT_CHOICE_KEYS = ["two_stage_slope", "section_mode", "ditch_side"] as const; /** 노선이 바뀔 때 통째로 넘기는 칸 — 사면 쉬프트(노견 확장) · 소단 깊이도(417 · 서버 `ROUTE_CARRY_KEYS`). */ -const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope", "shoulderext", "bermdepth"]; +const CARRY_WHOLE: readonly KeyedState[] = ["shoulderext", "bermdepth"]; +/** 자리 칸 통째(같은 자리만) — 암 경계 · 암 절토 경사. */ +const SEAT_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"]; /** * 노선 저장 때 새 노선 측점으로 옮기는 **누가거리 열쇠** 칸(측점 옮김 목록 · 417) → 담긴 설계 칸. @@ -75,9 +82,15 @@ const STRUCTURE_STATES = new Set([ /** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */ export const NEAR_M = 0.01; export const HINT = "@at"; +/** 칸 원본에 값마다 쓴 때 자리(`StationSeat`)를 담는 칸 — 되돌리기로 되살린 값도 제 자리로 판정(요구 275-3). */ +export const SEAT_HINT = "@seat"; -export type Keyed = { key: string; chainageM: number }; -export type Raw = Record & { [HINT]?: Record }; +/** 목록 측점 하나 — 자리(`seat`)를 모르면 같은 자리로 봄(`sameSeat`). */ +export type Keyed = { key: string; chainageM: number; seat?: StationSeat }; +export type Raw = Record & { + [HINT]?: Record; + [SEAT_HINT]?: Record; +}; /** 측점을 덮는 관 · 구조물(측점 계획 · 구조물 목록). */ export type Owner = { id: string; kind: "pipe" | "structure"; at: number; role: StationKeyRole }; /** 관 측점의 관 id — 관 장부(세션 `pipe-sN`) · 찾기만(`near`) · 없으면 지음(`at`). */ @@ -115,7 +128,19 @@ export function stationKeysFrom( : stationKey("irregular", at, intervalM); }); const chainages = sections.map((section) => section.chainage_m); - return dedupeStationKeys(keys, chainages).map((key, i) => ({ key, chainageM: chainages[i] })); + return dedupeStationKeys(keys, chainages).map((key, i) => ({ + key, + chainageM: chainages[i], + seat: seatOf(sections[i]), + })); +} + +/** 횡단 측점 자리(중심 · 왼쪽 축) — 좌표가 없으면 undefined. */ +export function seatOf(section: CrossSection): StationSeat | undefined { + const { center_x: x, center_y: y } = section; + if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined; + const left = section.frame?.left_xy; + return { x, y, left: left ? [left[0], left[1]] : null }; } /** 목록에서 그 자리(0.01 m 안) 가장 가까운 측점. */ @@ -161,12 +186,15 @@ export function curveMovesIn( return curveMoves(pairs(from), pairs(to)); } -/** 칸 원본 → [열쇠, 값, 쓴 때 자리]. */ -export function rawEntries(raw: Raw): Array<[string, V, number | undefined]> { +/** 칸 원본 → [열쇠, 값, 쓴 때 누가거리, 쓴 때 자리]. */ +export function rawEntries( + raw: Raw, +): Array<[string, V, number | undefined, StationSeat | undefined]> { const hints = raw[HINT] ?? {}; + const seats = raw[SEAT_HINT] ?? {}; return Object.entries(raw) - .filter(([key]) => key !== HINT) - .map(([key, value]) => [key, value as V, hints[key]]); + .filter(([key]) => key !== HINT && key !== SEAT_HINT) + .map(([key, value]) => [key, value as V, hints[key], seats[key]]); } /** 칸 원본을 목록 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌. */ @@ -179,13 +207,14 @@ export function readKeyedIn(raw: Raw, list: ReadonlyArray | undefined) return out; } -/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */ -function carriedValue(name: KeyedState, value: unknown): unknown { - if (CARRY_WHOLE.includes(name)) return value; +/** 칸마다 넘기는 값 — 넘길 것이 없으면 null · `seated` = 같은 자리(자리 칸도 넘김 · 요구 275-3). */ +function carriedValue(name: KeyedState, value: unknown, seated: boolean): unknown { + if (CARRY_WHOLE.includes(name) || (seated && SEAT_WHOLE.includes(name))) return value; if (name !== "crossdesign" || !value || typeof value !== "object") return null; const source = value as Record; + const keys = seated ? [...CARRY_CHOICE_KEYS, ...SEAT_CHOICE_KEYS] : CARRY_CHOICE_KEYS; const picked = Object.fromEntries( - CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]), + keys.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]), ); return Object.keys(picked).length ? picked : null; } @@ -203,22 +232,26 @@ export function carryKeyedStates( const curves = curveMovesIn(from, to); const states = {} as Record; for (const name of KEYED_STATES) { - const next: Raw = { [HINT]: {} }; - for (const [key, value] of rawEntries(read(name))) { + const next: Raw = { [HINT]: {}, [SEAT_HINT]: {} }; + for (const [key, value, , wroteAt] of rawEntries(read(name))) { // 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로 const fromAt = looseChainage(key) === null ? null : chainageIn(from, key); const was = fromAt === null ? key : keyIn(from, fromAt); const stable = curves.get(was) ?? was; const toAt = chainageIn(to, stable, looseChainage(stable) ?? undefined); - const carried = carriedValue(name, value); if (toAt === null || stable.startsWith("at:")) { - if (carried !== null) outside.add(stable); + if (carriedValue(name, value, true) !== null) outside.add(stable); continue; } - if (carried === null) continue; const toKey = keyIn(to, toAt); + // 값의 자리 = 쓴 때 자리(되돌리기로 되살린 값도) → 없으면 옛 목록 그 측점 자리 + const seat = wroteAt ?? from?.find((entry) => entry.key === was)?.seat; + const seated = sameSeat(seat, to && nearestKeyed(to, toAt)?.seat); + const carried = carriedValue(name, value, seated); + if (carried === null) continue; next[toKey] = carried; next[HINT]![toKey] = toAt; + if (seat) next[SEAT_HINT]![toKey] = seat; } states[name] = next; } diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts index 30e03b2c9..d17f03c52 100644 --- a/B06_Section/logic/B06_Section_Station_Swap.ts +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -11,6 +11,8 @@ * 안 덮음 · 467 · 요구 272) · 설계는 지금 규칙. * · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움). * · 상단측 = 새 지반 표본으로 다시 셈한 측점은 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428). + * · 노선 초안 차례 · 자리가 바뀐 측점(`sameSeat` 아님) = 앞 설계의 자리 칸(`SEAT_KEYS` · 암 경계 · 2단 경사 · 손 + * 상단측)을 걷고 기본값(단면 유형 = 새 상단측 깎기 · 요구 275-3 · 서버 노선 저장 · 넘김과 같은 규칙). * · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가 * 그 행을 지우고 기본 설계에 [저장] patch 를 얹음(`sync_cross_section_rows` · `compute_default_designs` · 428) · * patch 가 그 칸을 모두 실음(`crossPatchesFromCache` · 445 H2). @@ -27,7 +29,7 @@ import type { PipelineStation } from "../../A00_Common/design/logic/design_pipeline_types"; import type { CrossSection, SectionDetailResponse, SectionStation } from "../B06_Section_Api_Fetch"; import { uphillSide } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; -import { curveMoves } from "../../common_util/common_util_station_key"; +import { curveMoves, sameSeat } from "../../common_util/common_util_station_key"; interface Sample { offset_m: number; @@ -92,6 +94,19 @@ export const ROW_CARRY_KEYS = [ "berm", ] as const; +/** + * 자리 칸(요구 275-3) — 그 자리 지형에 묶인 사용자 값(암 경계 · 2단 경사 · 손 상단측 = 단면 유형 · 측구 방향). + * 노선이 바뀌면 같은 자리(`sameSeat`) 측점만 들고 감 · 새 자리 = 기본값 · 목록 정본 = 여기 하나(서버 + * `SEAT_KEYS` 가 이 글을 읽음 · 선언 꼴 그대로 둠). + */ +export const SEAT_KEYS = [ + "two_stage_slope", + "rock_boundary_offset_m", + "cut_slope_ratio_user", + "section_mode", + "ditch_side", +] as const; + /** 옮긴 시설 측점 설계 — 기본 설계 위에 앞 설계의 나머지(사용자 칸 · 지반 등 입력) · 결과 칸은 다시 셈이 갈음. */ function reseeded( prior: CrossSection["design"], @@ -104,6 +119,17 @@ function reseeded( return design as unknown as NonNullable; } +/** 자리가 바뀐 측점 설계 — 자리 칸을 걷고 기본값(암 경계 = config · 2단 경사 끔 · 단면 유형 = 상단측 깎기). */ +function unseated( + prior: CrossSection["design"], + uphill: CrossSection["uphill_side"], +): NonNullable { + const design = { ...(prior ?? {}) } as Record; + for (const key of SEAT_KEYS) delete design[key]; + design.section_mode = freshDesign(uphill).section_mode; + return design as unknown as NonNullable; +} + function kindOf(station: PipelineStation): SectionStation["kind"] { if (station.kind === "end") return station.key.endsWith("EP") ? "ep" : "bp"; if (station.kind === "curve") return "curve"; @@ -316,12 +342,27 @@ export function swapCrossSections( const reseed = (station.kind === "pipe" || station.kind === "structure") && Math.abs(prior.chainage_m - station.chainageM) > FACILITY_RESEED_M; + // 노선 초안 차례에 자리가 바뀜 = 자리 칸 기본값(요구 275-3) + const unseat = + routeDraft && + !sameSeat( + { x: prior.center_x, y: prior.center_y, left: prior.frame?.left_xy }, + { x: station.x, y: station.y, left: station.left }, + ); const next: CrossSection = { ...prior, ...place, uphill_side: uphill, samples, - ...(reseed ? { design: reseeded(prior.design, uphill) } : {}), + ...(reseed + ? { + design: unseat + ? unseated(reseeded(prior.design, uphill), uphill) + : reseeded(prior.design, uphill), + } + : unseat && prior.design + ? { design: unseated(prior.design, uphill) } + : {}), }; if (!onGrid) delete next.grid; // 격자 자리를 떠난 시설 측점 — 옛 숨은 일반 측점 표시를 안 들고 감(458) // 묶음 측점 = 정본 목록 — 초안 맞춤의 「구조물 없는 사본」 표시를 안 들고 감(들고 가면 다음 맞춤이 그 측점을 버림) diff --git a/common_util/common_util_station_key.py b/common_util/common_util_station_key.py index 48d8df3bd..36427cf00 100644 --- a/common_util/common_util_station_key.py +++ b/common_util/common_util_station_key.py @@ -107,3 +107,35 @@ def curve_moves(before: list[tuple[str, float]], after: list[tuple[str, float]]) if best >= 0: out[key] = free.pop(best)[0] return out + + +#: 같은 자리로 보는 중심 차(m) · 축 차 — 브라우저 틀 ↔ 서버 틀 차(467)를 넘김. +SEAT_XY_M = 0.01 +SEAT_AXIS = 1e-3 + + +def station_seat(station: dict | None) -> tuple[float, float, tuple[float, float] | None] | None: + """종단 측점 줄(``center_x`` · ``center_y`` · ``frame.left_xy``) → 자리 · 모르면 None.""" + if not isinstance(station, dict): + return None + try: + x, y = float(station["center_x"]), float(station["center_y"]) + except (KeyError, TypeError, ValueError): + return None + left = (station.get("frame") or {}).get("left_xy") + axis = (float(left[0]), float(left[1])) if isinstance(left, list) and len(left) == 2 else None + return x, y, axis + + +def same_seat(a, b) -> bool: + """노선이 바뀐 뒤 그 측점이 같은 자리인가(요구 275-3) — 짝 ``sameSeat`` · 거울 475-3. + + 자리 = (x, y, 왼쪽 단위 벡터 | None) · 한쪽을 모르면 같은 자리로 봄(옛 동작). + """ + if a is None or b is None: + return True + if math.hypot(a[0] - b[0], a[1] - b[1]) > SEAT_XY_M: + return False + if a[2] is None or b[2] is None: + return True + return abs(a[2][0] - b[2][0]) <= SEAT_AXIS and abs(a[2][1] - b[2][1]) <= SEAT_AXIS diff --git a/common_util/common_util_station_key.ts b/common_util/common_util_station_key.ts index 842ba8dee..c8c44bdfe 100644 --- a/common_util/common_util_station_key.ts +++ b/common_util/common_util_station_key.ts @@ -97,3 +97,30 @@ export function curveMoves( } return out; } + +/** 측점 자리 — 중심 좌표 · 왼쪽 단위 벡터(횡단 축 · 없으면 좌표만 봄). */ +export interface StationSeat { + x: number; + y: number; + left?: readonly [number, number] | null; +} +/** 같은 자리로 보는 중심 차(m) · 축 차 — 브라우저 틀 ↔ 서버 틀 차(467)를 넘김. */ +export const SEAT_XY_M = 0.01; +export const SEAT_AXIS = 1e-3; + +/** + * 노선이 바뀐 뒤 그 측점이 같은 자리인가(요구 275-3) — 자리 · 축이 같으면 지형이 같아 「자리 칸」(암 경계 · 2단 + * 경사 · 손 상단측)을 들고 감 · 한쪽 자리를 모르면 같은 자리로 봄(옛 목록 · 옛 동작) · 짝 `same_seat` · + * 거울 시험 `test_475_3_seat_carry.py`. + */ +export function sameSeat( + a: StationSeat | null | undefined, + b: StationSeat | null | undefined, +): boolean { + if (!a || !b) return true; + if (Math.hypot(a.x - b.x, a.y - b.y) > SEAT_XY_M) return false; + if (!a.left || !b.left) return true; + return ( + Math.abs(a.left[0] - b.left[0]) <= SEAT_AXIS && Math.abs(a.left[1] - b.left[1]) <= SEAT_AXIS + ); +} diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index 28d6ab47c..09c505fd5 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -28,6 +28,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | [저장] · [확정] = 서버가 입력만으로 정본 다시 셈 · 브라우저 결과(면적 · 표본 · 측점) 받아 적지 않음 | `design_save.ts:58-68` · `B06_Section_Router_Confirm.py:203-215` | `test_b06_save_recompute` | | 자동저장(디바운스 PUT) 없음 · 화면 잠금 = [저장] · [확정] · [초기화] · 추천 [적용]만 | `design_save.ts:249` | — | | 측점 열쇠 = `common_util_station_key` 한 벌 · 노선 바뀜 = 관 · 구조물 좌표(x · y) 따라감 · 누가거리 다시 매김 | `common_util_station_key.ts:32` · `common_util_route_follow.ts:291` | `test_344_3_station_key_mirror` · `test_410_follow_xy` | +| 노선 바뀜 넘김 = 열쇠마다 지반 · 측구 종류 · 측구 선택 · 사면 쉬프트 · 소단 · 소단 깊이 · 자리 칸(`SEAT_KEYS` = 암 경계 · 암 절토 경사 · 2단 경사 · 손 상단측 = 단면 유형 · 측구 방향)은 같은 자리(`sameSeat` · 중심 0.01 m · 축 1e-3) 측점만 · 새 자리 = 기본값 · 세션 값은 쓴 때 자리(`@seat`)로 판정(↶ ↷ 유지) · 끌기 갈아끼움 · 미리보기 · Node 넘김 · 서버 노선 저장 한 규칙 | `logic/B06_Section_Station_Swap.ts` SEAT_KEYS · unseated · `logic/B06_Section_Route_Carry.ts` carryKeyedStates · `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts` crossAtSeat · `B05_Profile_Route_Save_Parts.py` carry_cross_inputs | `test_475_3_seat_carry` | | 노선 초안 ↶(버림) = 차례 하나 · 버림 동안 종단도 그리기 멈춤 · 낡음 다시 셈 미룸 → 되돌림 · 저장 기준 맞춤 · 뒤 차례 끝에 한 번 그림(사이 섞인 유토곡선 없음) | `design_pipeline_page.ts:642 · 403` · `B06_Section_Section_Store.ts:316` | `test_472_5c_drop_once` | | B05 · B06 캐시 = 한 벌 · [저장] 한 번에 함께 나감 | `design_shared.ts:395-448` · `design_save.ts:237` | — | | 관 · 구조물 주인 = 가게 칸 하나 — 화면 사본은 가게 알림으로 맞춤(제 쓰기는 같은 값 · 남 고침은 다시 실음) | `design_view_profile.ts` 가게 구독 · `design_view_cross.ts` onShop · `B06_Section_UI_Page_Structures_Panel.ts` seen | `test_472_5_shop_owner` | diff --git a/resources/tester/helper_344_3_station_keys.mjs b/resources/tester/helper_344_3_station_keys.mjs index 99ff8dd34..6e889fa18 100644 --- a/resources/tester/helper_344_3_station_keys.mjs +++ b/resources/tester/helper_344_3_station_keys.mjs @@ -38,7 +38,7 @@ const R2 = 8; const raw = (name, route) => m.readState(name, P, route) ?? {}; const keysOf = (name, route) => Object.keys(raw(name, route)) - .filter((k) => k !== "@at") + .filter((k) => !k.startsWith("@")) // 「@at」 · 「@seat」 = 쓴 때 자리 칸(475-3) .sort(); const section = (chainage_m, kind, extra = {}) => ({ station_id: `s${chainage_m}`, diff --git a/resources/tester/helper_346_2_station_swap.mjs b/resources/tester/helper_346_2_station_swap.mjs index c69385b1f..cad9201dd 100644 --- a/resources/tester/helper_346_2_station_swap.mjs +++ b/resources/tester/helper_346_2_station_swap.mjs @@ -173,7 +173,7 @@ const NEW = (dy) => [ .sort((a, b) => a[0] - b[0]) .map(([at, c]) => [at, c.ground_type, c.section_mode ?? null]), rawKeys: Object.keys(m.readState("crossdesign", P, R) ?? {}) - .filter((k) => k !== "@at") + .filter((k) => !k.startsWith("@")) .sort(), widths: m.readState("crossw", P, R), redraws, diff --git a/resources/tester/helper_415_swap.mjs b/resources/tester/helper_415_swap.mjs index 61270a445..78f45396f 100644 --- a/resources/tester/helper_415_swap.mjs +++ b/resources/tester/helper_415_swap.mjs @@ -97,10 +97,10 @@ const snap = () => ({ .sort((a, b) => a[0] - b[0]) .map(([at, c]) => [at, c.ground_type ?? null, c.section_mode ?? null]), shoulder: Object.entries(m.readState("shoulderext", P, R) ?? {}) - .filter(([k]) => k !== "@at") + .filter(([k]) => !k.startsWith("@")) .map(([k, v]) => [k, v]), berm: Object.entries(m.readState("bermdepth", P, R) ?? {}) - .filter(([k]) => k !== "@at") + .filter(([k]) => !k.startsWith("@")) .map(([k, v]) => [k, v]), }); diff --git a/resources/tester/helper_417_route_carry.mjs b/resources/tester/helper_417_route_carry.mjs index 94b520113..979a7ceda 100644 --- a/resources/tester/helper_417_route_carry.mjs +++ b/resources/tester/helper_417_route_carry.mjs @@ -38,7 +38,7 @@ const R2 = 8; const raw = (name, route) => m.readState(name, P, route) ?? {}; const keysOf = (name, route) => Object.keys(raw(name, route)) - .filter((k) => k !== "@at") + .filter((k) => !k.startsWith("@")) // 「@at」 · 「@seat」 = 쓴 때 자리 칸(475-3) .sort(); const section = (chainage_m, kind, extra = {}) => ({ station_id: `s${chainage_m}`, diff --git a/resources/tester/helper_440_draft_card_ids.cjs b/resources/tester/helper_440_draft_card_ids.cjs index 903102821..443ddf9f8 100644 --- a/resources/tester/helper_440_draft_card_ids.cjs +++ b/resources/tester/helper_440_draft_card_ids.cjs @@ -6,7 +6,7 @@ const ROOT = path.resolve(__dirname, "..", ".."); const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`; const REAL = - /(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_\w+)$/; + /(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|B06_Section_Route_Carry|design_pipeline_cross|common_util_\w+)$/; // Route_Carry = 자리 칸 「@seat」(475-3) const CATALOG = require("./helper_322_catalog_module.cjs"); const STUBS = { B05_Profile_Pipeline_Grade: { stationIdOf: idOf }, diff --git a/resources/tester/helper_475_3_entry.ts b/resources/tester/helper_475_3_entry.ts new file mode 100644 index 000000000..9044c4d64 --- /dev/null +++ b/resources/tester/helper_475_3_entry.ts @@ -0,0 +1,5 @@ +/* 475-3 시험 묶음 진입점 — 자리 칸(암 경계 · 2단 경사 · 손 상단측) 넘김 규칙을 화면 · 서버 없이 잰다. */ +export { sameSeat } from "../../common_util/common_util_station_key"; +export { carryKeyedStates } from "../../B06_Section/logic/B06_Section_Route_Carry"; +export { crossAtSeat } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +export { swapCrossSections } from "../../B06_Section/logic/B06_Section_Station_Swap"; diff --git a/resources/tester/helper_475_3_seat_carry.mjs b/resources/tester/helper_475_3_seat_carry.mjs new file mode 100644 index 000000000..3ff1fe365 --- /dev/null +++ b/resources/tester/helper_475_3_seat_carry.mjs @@ -0,0 +1,135 @@ +/* 475-3 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_475_3_entry.ts)을 부르고 결과를 JSON 한 벌로. + * node helper_475_3_seat_carry.mjs <묶음.mjs> <입력.json> + * 입력 = { seats: [[a, b], …] }(자리 판정 거울 사례). + */ +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const [bundle, inputPath] = process.argv.slice(2); +const m = await import(pathToFileURL(bundle).href); +const input = JSON.parse(readFileSync(inputPath, "utf-8")); +const out = {}; + +out.sameSeat = input.seats.map(([a, b]) => m.sameSeat(a, b)); + +/* 노선 바뀜 넘김 — grid:1 같은 자리 · grid:2 옮김 · grid:3 옮겼지만 값은 새 자리에서 씀(되돌리기로 되살린 값) */ +const A = { x: 0, y: 20, left: [-1, 0] }; +const B = { x: 0, y: 40, left: [-1, 0] }; +const C = { x: 0, y: 60, left: [-1, 0] }; +const C2 = { x: 3, y: 60, left: [-1, 0] }; +const from = [ + { key: "grid:1", chainageM: 20, seat: A }, + { key: "grid:2", chainageM: 40, seat: B }, + { key: "grid:3", chainageM: 60, seat: C }, +]; +const to = [ + { key: "grid:1", chainageM: 20, seat: A }, + { key: "grid:2", chainageM: 40.5, seat: { x: 5, y: 40, left: [-1, 0] } }, + { key: "grid:3", chainageM: 61, seat: C2 }, +]; +const choice = { + ground_type: "soil", + ditch_type: "l_type", + two_stage_slope: true, + section_mode: "right_cut", + ditch_side: "left", + paved: true, +}; +const raw = { + rockb: { "grid:1": 1.2, "grid:2": 1.5, "grid:3": 0.9, "@seat": { "grid:3": C2 } }, + cutslope: { "grid:1": 0.8, "grid:2": 0.7 }, + crossdesign: { "grid:1": choice, "grid:2": choice }, + shoulderext: { "grid:2": { left: 0.5 } }, + bermdepth: {}, +}; +const strip = (states) => + Object.fromEntries( + Object.entries(states).map(([name, cells]) => [ + name, + Object.fromEntries(Object.entries(cells).filter(([key]) => !key.startsWith("@"))), + ]), + ); +const carried = m.carryKeyedStates((name) => raw[name] ?? {}, from, to); +out.carried = strip(carried.states); +out.carriedSeats = carried.states.rockb["@seat"]; +out.outside = carried.outside.size; +/* 자리 모름(옛 목록) = 옛 동작 그대로 */ +const bare = (list) => list.map(({ key, chainageM }) => ({ key, chainageM })); +out.unknown = strip(m.carryKeyedStates((name) => raw[name] ?? {}, bare(from), bare(to)).states); + +/* 미리보기 입력 — 저장분 · 세션 자리 칸 묶음(`seats`) */ +const station = (key, x, y) => ({ key, chainageM: y, x, y, left: [-1, 0] }); +const input2 = { + standard: {}, + byKey: { + "grid:1": { + groundType: "soil", + rockBoundaryOffsetM: 1, + sectionMode: "right_cut", + seats: [{ ...A, fields: ["rockBoundaryOffsetM", "sectionMode"] }], + }, + "grid:2": { + groundType: "soil", + twoStageSlope: true, + cutSlopeRatio: 0.7, + seats: [ + { ...B, fields: ["twoStageSlope"] }, + { x: 5, y: 40, left: [-1, 0], fields: ["cutSlopeRatio"] }, + ], + }, + }, +}; +out.atSeat = m.crossAtSeat(input2, [station("grid:1", 0, 20), station("grid:2", 5, 40)]).byKey; + +/* B06 갈아끼움(노선 초안) — 앞 횡단 설계를 이어 받되 자리가 바뀐 측점은 자리 칸 기본값 */ +const hand = { + ground_type: "soil", + section_mode: "both_cut", + ditch_side: "right", + two_stage_slope: true, + rock_boundary_offset_m: -1.2, + cut_slope_ratio_user: 0.8, + shoulder_extend_left_m: 0.5, +}; +const section = (chainage, x) => ({ + station_id: `s${chainage}`, + chainage_m: chainage, + kind: "regular", + center_x: x, + center_y: chainage, + frame: { left_xy: [-1, 0] }, + samples: [], + uphill_side: "left", + design: { ...hand }, +}); +const pipelineStation = (key, chainage, x) => ({ + key, + kind: "grid", + chainageM: chainage, + x, + y: chainage, + left: [-1, 0], + azimuthDeg: 0, + planRadiusM: null, + curveOuterSide: null, + curveWideningM: 0, + ground: null, +}); +const detail = { + longitudinal: { stations: [], length_m: 40 }, + cross_sections: [section(20, 0), section(40, 0)], +}; +const swapped = (draft) => + m + .swapCrossSections( + detail, + [pipelineStation("grid:1", 20, 0), pipelineStation("grid:2", 40, 5)], + ["grid:1", "grid:2"], + 20, + draft, + ) + .detail.cross_sections.map((s) => s.design); +out.swapDraft = swapped(true); +out.swapNoDraft = swapped(false); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_344_3_station_keys.py b/resources/tester/test_344_3_station_keys.py index 37430c880..aff239632 100644 --- a/resources/tester/test_344_3_station_keys.py +++ b/resources/tester/test_344_3_station_keys.py @@ -97,11 +97,15 @@ def test_노선_바뀜_측점_열쇠로_넘김(out): assert out["outside"] == 2 # 60 m(노선이 짧아짐) · 99 m(옛 노선에도 없던 옛 초안) assert out["carriedKeys"] == ["grid:1", "grid:2", "pipe:pipe-s1", "structure:st-1"] assert out["carriedRead"] == [20, 40, 47, 55] # 관 · 구조물은 옮긴 자리로 + # 자리 칸(2단 경사 · 손 상단측) = 같은 자리만(475-3) — 이 측점들은 좌표가 없어(자리 모름) + # 같은 자리로 봄 · 옛 단언(단면 유형 · 측구 방향은 늘 새 지형)을 고침 · 포장은 안 넘김 assert out["carriedChoice"] == { "ground_type": "soil", "ditch_type": "l_type", "ditch_choice": False, "two_stage_slope": True, + "section_mode": "right_cut", + "ditch_side": "right", } assert out["carriedRock"] == {"20.00": -0.8} assert out["carriedShoulder"] == ["grid:2"] # 사면 쉬프트도 넘김(417) diff --git a/resources/tester/test_472_6_route_save.py b/resources/tester/test_472_6_route_save.py index fd40a72be..14e732180 100644 --- a/resources/tester/test_472_6_route_save.py +++ b/resources/tester/test_472_6_route_save.py @@ -97,7 +97,9 @@ def test_넘김_값_사용자_칸(out, carried) -> None: by = _by_chainage(carried["patches"]) assert by["20.00"]["ground_type"] == "soil" and by["20.00"]["two_stage_slope"] is True assert by["20.00"]["ditch_type"] == "u_flume" - assert "section_mode" not in by["20.00"] # 단면 유형 = 새 지형이 정함 + # 손 단면 유형 = 자리 칸(475-3) — 이 목록은 자리를 몰라(옛 목록) 같은 자리로 봄 · 넘김 + # (옛 단언 = 늘 새 지형) + assert by["20.00"]["section_mode"] == "left_cut" assert by["20.00"]["structure_note"] == "앞 메모" assert by["40.00"]["shoulder_extend_left_m"] == 0.5 assert "revet_adjust" not in by["40.00"] # 일반 측점 옹벽 조절 = 안 넘김(관 · 구조물 열쇠만) diff --git a/resources/tester/test_475_3_seat_carry.py b/resources/tester/test_475_3_seat_carry.py new file mode 100644 index 000000000..c23b0bf56 --- /dev/null +++ b/resources/tester/test_475_3_seat_carry.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +"""475-3(요구 275-3) — 노선이 바뀌면 자리 칸은 같은 자리 측점만 들고 감. + +자리 칸 = 암 경계 · 2단 경사 · 손 상단측(TS `SEAT_KEYS` 하나 · 파이썬은 그 글을 읽음). + +못박는 것 + - 자리 판정 거울 — TS `sameSeat` ↔ 파이썬 `same_seat`(중심 0.01 m · 왼쪽 축 1e-3 · + 모르면 같은 자리). + - 브라우저 · Node 넘김(`carryKeyedStates`) — 같은 자리 = 자리 칸도 · 옮긴 자리 = 자리 칸 + 버림(새 자리 기본값) · 지반 · 측구 종류 · 사면 쉬프트는 그대로 · 값의 쓴 때 자리(`@seat` · + 되돌리기로 되살린 값)가 새 자리와 같으면 유지 · 자리 모르는 옛 목록 = 옛 동작. + - 미리보기 입력(`crossAtSeat`) — 자리 칸 묶음이 측점 자리와 다르면 뺌 · 같으면 그대로. + - 서버 노선 저장 넘김(`carry_cross_inputs` · `seats`) — 같은 규칙 · `seats` 없으면 옛 동작. + - 기본 설계가 넘긴 손 단면 유형으로 셈(`compute_default_designs` · 없으면 상단측 깎기). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_475_3_seat_carry.py -q +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Sections import find_cross_file # noqa: E402 +from B05_Profile.B05_Profile_Route_Save_Parts import carry_cross_inputs, station_seats # noqa: E402 +from B06_Section.B06_Section_Router_Design import ( # noqa: E402 + CARRY_INPUT_KEYS, + SEAT_KEYS, + compute_default_designs, +) +from common_util.common_util_station_key import same_seat, station_seat # noqa: E402 + +SEATS = [ + [{"x": 0, "y": 0, "left": [1, 0]}, {"x": 0.009, "y": 0, "left": [1, 0]}], + [{"x": 0, "y": 0, "left": [1, 0]}, {"x": 0.011, "y": 0, "left": [1, 0]}], + [{"x": 0, "y": 0, "left": [1, 0]}, {"x": 0, "y": 0, "left": [0.998, 0.002]}], + [{"x": 0, "y": 0, "left": [1, 0]}, {"x": 0, "y": 0, "left": [0.9995, 0.0009]}], + [{"x": 0, "y": 0, "left": None}, {"x": 0, "y": 0.005, "left": [0, 1]}], + [None, {"x": 9, "y": 9, "left": [0, 1]}], +] + + +def _py_seat(seat): + if seat is None: + return None + left = seat.get("left") + return seat["x"], seat["y"], tuple(left) if left else None + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b475_3") + build = subprocess.run( + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_475_3_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = next(work.glob("*.js"), None) or next(work.glob("*.mjs")) + inputs = work / "input.json" + inputs.write_text(json.dumps({"seats": SEATS}), "utf-8") + proc = subprocess.run( + ["node", str(HERE / "helper_475_3_seat_carry.mjs"), str(bundle), str(inputs)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_자리_칸_목록_정본은_TS_하나(): + swap = (ROOT / "B06_Section" / "logic" / "B06_Section_Station_Swap.ts").read_text("utf-8") + body = re.search(r"export const SEAT_KEYS = \[(.*?)\] as const;", swap, re.S) + assert tuple(re.findall(r'"([a-z_0-9]+)"', body.group(1))) == SEAT_KEYS + assert SEAT_KEYS == ( + "two_stage_slope", + "rock_boundary_offset_m", + "cut_slope_ratio_user", + "section_mode", + "ditch_side", + ) + + +def test_자리_판정_거울(out): + assert out["sameSeat"] == [same_seat(_py_seat(a), _py_seat(b)) for a, b in SEATS] + assert out["sameSeat"] == [True, False, False, True, True, True] + + +def test_같은_자리는_자리_칸도_옮긴_자리는_기본값(out): + carried = out["carried"] + assert carried["rockb"] == { + "grid:1": 1.2, + "grid:3": 0.9, + } # grid:2 옮김 = 버림 · grid:3 = 쓴 때 자리 + assert carried["cutslope"] == {"grid:1": 0.8} + assert carried["crossdesign"]["grid:1"] == { # 포장은 안 넘김(138-1) + "ground_type": "soil", + "ditch_type": "l_type", + "two_stage_slope": True, + "section_mode": "right_cut", + "ditch_side": "left", + } + assert carried["crossdesign"]["grid:2"] == {"ground_type": "soil", "ditch_type": "l_type"} + assert carried["shoulderext"] == {"grid:2": {"left": 0.5}} # 자리 칸 아님 = 그대로 + assert out["carriedSeats"]["grid:3"] == {"x": 3, "y": 60, "left": [-1, 0]} + assert out["outside"] == 0 + + +def test_자리_모르는_옛_목록은_옛_동작(out): + unknown = out["unknown"] + assert unknown["rockb"] == {"grid:1": 1.2, "grid:2": 1.5, "grid:3": 0.9} + assert unknown["crossdesign"]["grid:2"]["two_stage_slope"] is True + + +def test_미리보기_입력_자리_칸(out): + at = out["atSeat"] + assert at["grid:1"] == { + "groundType": "soil", + "rockBoundaryOffsetM": 1, + "sectionMode": "right_cut", + } + # grid:2 = 저장 자리(0, 40)에서 온 2단 경사는 뺌 · 새 자리(5, 40)에서 쓴 암 경사는 그대로 + assert at["grid:2"] == {"groundType": "soil", "cutSlopeRatio": 0.7} + + +def test_B06_갈아끼움_자리_바뀐_측점은_자리_칸_기본값(out): + same, moved = out["swapDraft"] + assert same["rock_boundary_offset_m"] == -1.2 and same["section_mode"] == "both_cut" + assert moved == { + "ground_type": "soil", + "section_mode": "left_cut", + "shoulder_extend_left_m": 0.5, + } + # 노선 초안 아닌 차례(관 · 구조물 · 추가 측점만) = 이어 받은 설계 그대로 + assert out["swapNoDraft"][1]["two_stage_slope"] is True + + +def _row(chainage, x, left=(-1.0, 0.0)): + return { + "chainage_m": chainage, + "center_x": x, + "center_y": chainage, + "frame": {"left_xy": list(left)}, + } + + +def test_서버_노선_저장_넘김_같은_규칙(): + old_keys = {"grid:1": 20.0, "grid:2": 40.0} + new_keys = {"grid:1": 20.0, "grid:2": 40.5} + old_rows = [_row(20.0, 0.0), _row(40.0, 0.0)] + new_rows = [_row(20.0, 0.0), _row(40.5, 5.0)] + seats = (station_seats(old_rows, old_keys), station_seats(new_rows, new_keys)) + hand = { + "ground_type": "soil", + "two_stage_slope": True, + "rock_boundary_offset_m": 1.5, + "cut_slope_ratio_user": 0.8, + "section_mode": "right_cut", + "ditch_side": "left", + "shoulder_extend_left_m": 0.5, + } + designs = {20.0: dict(hand), 40.0: dict(hand)} + keys = (*CARRY_INPUT_KEYS, "shoulder_extend_left_m") + inputs, _, dropped = carry_cross_inputs(old_keys, new_keys, designs, keys, (), SEAT_KEYS, seats) + assert inputs[20.0] == hand + assert inputs[40.5] == {"ground_type": "soil", "shoulder_extend_left_m": 0.5} + assert dropped == 0 + # seats 없음 = 옛 동작(손 상단측은 안 넘김 · 자리 칸 셋은 넘김) + old, _, _ = carry_cross_inputs(old_keys, new_keys, designs, keys, ()) + assert old[40.5] == {k: v for k, v in hand.items() if k not in ("section_mode", "ditch_side")} + assert station_seat({"chainage_m": 1}) is None + + +def test_기본_설계가_넘긴_손_단면_유형으로_셈(tmp_path, monkeypatch): + """엔진 입력 = 넘긴 단면 유형 · 측구 방향(결과 단면 유형은 엔진이 지형으로 다시 판정).""" + import B06_Section.B06_Section_Router_Design as design_module + + seen: list[tuple[str, object]] = [] + real = design_module.compute_cross_design + + def spy(*args, **kwargs): + seen.append((kwargs["section_mode"], kwargs.get("ditch_side"))) + return real(*args, **kwargs) + + monkeypatch.setattr(design_module, "compute_cross_design", spy) + route = tmp_path / "routes" / "r1" + (route / "longitudinal").mkdir(parents=True) + (route / "cross_sections").mkdir() + profile = [ + {"chainage_m": 0.0, "elevation_m": 100.0}, + {"chainage_m": 40.0, "elevation_m": 100.0}, + ] + relative = "routes/r1/longitudinal/longitudinal.json" + stations = [{"chainage_m": 20.0, "uphill_side": "left"}] + (tmp_path / relative).write_text( + json.dumps({"stations": stations, "design_profiles": [{"samples": profile}]}), "utf-8" + ) + samples = [ + {"offset_m": float(o), "elevation_m": 100.0 + 0.3 * o, "valid": True} + for o in range(-15, 16) + ] + cross = {"chainage_m": 20.0, "samples": samples, "uphill_side": "left"} + find_cross_file(route / "cross_sections", 20.0).write_text(json.dumps(cross), "utf-8") + plain = compute_default_designs(tmp_path, relative, [20.0]) + hand = compute_default_designs( + tmp_path, + relative, + [20.0], + carried={20.0: {"section_mode": "both_cut", "ditch_side": "right"}}, + ) + assert plain and hand + assert seen == [("left_cut", None), ("both_cut", "right")]