Merge remote-tracking branch 'origin/dev' into sub_laptop_5
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_pipeline_cross.ts
|
||||
* 계산 묶음 cross 입력 모음(PLAN 346-2 붙임) — 저장 노선 상세의 측점마다 횡단 입력 · 얹힌 시설 제원을
|
||||
* 계산 묶음 cross 입력 모음(PLAN 346-2 붙임) — 저장 노선 상세의 측점마다 사용자 횡단 입력 · 카드 번호를
|
||||
* **측점 열쇠**로 모음. 노선이 바뀌어도 같은 열쇠 측점(격자 번호 · 관 id · 구조물 id)이 그 입력을 받음.
|
||||
* 열쇠 = 저장 노선을 stations 단계와 같은 함수로 짚은 측점(`buildStations`)과 누가거리로 짝지음 —
|
||||
* 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id).
|
||||
* 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id). 초안 자리로 뜬 시설 측점 = 등록 열쇠(주인 id)로 짝(457).
|
||||
*
|
||||
* 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 ·
|
||||
* 암 경계 · 암 경사 · 소단) + 사면 쉬프트(노견 확장) · 소단 깊이(417 · 서버 `ROUTE_CARRY_KEYS`) + 얹힌 시설
|
||||
* 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구 방향은 새 지형이 정함
|
||||
* (`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`). 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 ·
|
||||
* 암 경계 · 암 경사 · 소단) + 사면 쉬프트(노견 확장) · 소단 깊이(417 · 서버 `ROUTE_CARRY_KEYS`) — 사용자 칸만.
|
||||
* 그림 자료 · 카드 이름 · 얹힌 시설 맥락(포장 · 기울임 · 하강) · 단면 유형 · 측구 방향은 새 자리에서 다시 셈
|
||||
* (`logic/B05_Profile_Pipeline_Detail.ts` `crossWithShapes` · `draftSectionDetail` · `crossWithUphill` · 457). 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 ·
|
||||
* 지반 유형 · 측구 · 2단 경사 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에
|
||||
* 안 얹혀도 입력에 들어감(365-2 · 365-5 · 417). 부를 때마다 모음.
|
||||
* ========================================================================== */
|
||||
@@ -25,21 +25,10 @@ import type { PipelineStation } from "./logic/design_pipeline_types";
|
||||
|
||||
export interface PipelineCrossSource {
|
||||
input: CrossInput;
|
||||
/** 측점 열쇠 → 얹힌 시설 제원. */
|
||||
/** 측점 열쇠 → 카드 번호(457 · 그림 자료는 새 자리에서 다시 얹음). */
|
||||
specs: Map<string, SectionSpecs>;
|
||||
}
|
||||
|
||||
const SPEC_KEYS = [
|
||||
"structure",
|
||||
"culvert",
|
||||
"ford",
|
||||
"box",
|
||||
"ford_pavement",
|
||||
"revetment",
|
||||
"pavement",
|
||||
"underdrain",
|
||||
] as const;
|
||||
|
||||
/** 같은 측점으로 보는 누가거리 차(m) — 측점 열쇠 글 자릿수(0.01 m). */
|
||||
const SAME_M = 0.01;
|
||||
|
||||
@@ -58,13 +47,12 @@ export const forceCross = (): void => void (forced += 1);
|
||||
const finite = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
/** 측점 하나의 넘기는 입력(cross 단계 `CrossStationInput` 꼴). */
|
||||
/** 측점 하나의 넘기는 사용자 입력(cross 단계 `CrossStationInput` 꼴 · 시설 맥락은 새 자리 그림에서 · 457). */
|
||||
function carried(section: CrossSection, saved?: CrossSection["design"]): Record<string, unknown> {
|
||||
const design = (saved ?? section.design ?? {}) as unknown as Record<string, unknown>;
|
||||
const berm = design.berm as
|
||||
{ width_m: number; interval_m: number; slope_deg: number; depth_m?: number } | undefined;
|
||||
const cutSlope = finite(design.cut_slope_ratio_user);
|
||||
const drop = Number(section.ford?.overflow_depth_m);
|
||||
return {
|
||||
...(typeof design.ground_type === "string" ? { groundType: design.ground_type } : {}),
|
||||
...(typeof design.ditch_type === "string" ? { ditchType: design.ditch_type } : {}),
|
||||
@@ -82,10 +70,6 @@ function carried(section: CrossSection, saved?: CrossSection["design"]): Record<
|
||||
depthM: berm.depth_m,
|
||||
}
|
||||
: null,
|
||||
paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement),
|
||||
tilted: Boolean(section.ford_pavement || section.underdrain),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
|
||||
surfaceDropM: drop > 0 ? drop : 0,
|
||||
// 저장분 사면 쉬프트 · 소단 깊이(417 L4) — 세션 고침이 없어도 저장 뒤 그대로
|
||||
...sides(design, "shoulder_extend", "shoulderExtend"),
|
||||
...sides(design, "berm_depth", "bermDepth"),
|
||||
@@ -147,29 +131,30 @@ function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"]
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 시설 제원 — 표준단면 ·
|
||||
* 상세가 없으면 null. */
|
||||
/** 초안 자리로 뜬 측점 → 처음 본 등록 열쇠 — 노선 초안 동안 등록 목록이 새 노선 것으로 바뀌어도 그대로(457). */
|
||||
const strayKeys = new WeakMap<CrossSection, string>();
|
||||
|
||||
/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 카드 번호 — 표준단면 ·
|
||||
* 상세가 없으면 null. `keysOf` = 상세 측점마다 등록 열쇠(B06 측점 열쇠 목록 · 초안 자리 측점 짝 · 457). */
|
||||
export function pipelineCrossSource(
|
||||
projectId: string,
|
||||
detail: SectionDetailResponse | null,
|
||||
savedStations: ReadonlyArray<PipelineStation>,
|
||||
routeId: number | null = null,
|
||||
keysOf?: (routeId: number, detail: SectionDetailResponse) => string[],
|
||||
): PipelineCrossSource | null {
|
||||
const standard = detail && effectiveStandardCross(projectId);
|
||||
if (!detail || !standard) return null;
|
||||
const byKey: CrossInput["byKey"] = {};
|
||||
const specs = new Map<string, SectionSpecs>();
|
||||
// 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 365-5) ·
|
||||
// 시설 제원 = 화면 상세(초안 형상을 얹은 것)
|
||||
// 설계 칸 = 세션에 남긴 저장 상세(초안 · 다시 셈이 안 고친 정본 — 들어온 화면 · 고친 차례 무관 · 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 => {
|
||||
byKey[key] = carried(section, storedAt(at));
|
||||
// 카드 번호 = 저장 카드 번호(같은 측점 열쇠 · 옮긴 관도 · 찾기 · 고름이 같은 id · 440)
|
||||
const own: Record<string, unknown> = { station_id: section.station_id };
|
||||
for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name];
|
||||
specs.set(key, own as SectionSpecs);
|
||||
specs.set(key, { station_id: section.station_id });
|
||||
};
|
||||
const strays: CrossSection[] = [];
|
||||
for (const section of detail.cross_sections) {
|
||||
@@ -182,20 +167,25 @@ export function pipelineCrossSource(
|
||||
if (best && !byKey[best.key]) put(best.key, section);
|
||||
else if (!best && section.kind === "irregular") strays.push(section);
|
||||
}
|
||||
// 초안 자리로 미리 뜬 관 · 구조물 측점(초안 둔 채 열기 — 상세가 초안 누가거리) = 남은 저장 시설 측점과 차례로 짝 ·
|
||||
// 수가 다르면 안 짝지음(349-0d 와 같은 규칙 · 들어온 화면 무관 같은 제원 · 365-5)
|
||||
const left = savedStations.filter(
|
||||
(station) => (station.kind === "pipe" || station.kind === "structure") && !byKey[station.key],
|
||||
);
|
||||
left.sort((a, b) => a.chainageM - b.chainageM);
|
||||
if (strays.length === left.length)
|
||||
strays.forEach((section, i) => put(left[i].key, section, left[i].chainageM));
|
||||
// 초안 자리로 미리 뜬 관 · 구조물 측점(초안 둔 채 열기 — 상세가 초안 누가거리) = 등록 열쇠(주인 id)가 같은 저장
|
||||
// 시설 측점과 짝 — 차례로 짝짓지 않음(엇갈려 옮긴 관 · 457) · 모르는 열쇠는 버림
|
||||
const keys = strays.length && routeId !== null ? (keysOf?.(routeId, detail) ?? null) : null;
|
||||
for (const section of strays) {
|
||||
const index = detail.cross_sections.indexOf(section);
|
||||
const key = strayKeys.get(section) ?? keys?.[index];
|
||||
if (!key) continue;
|
||||
strayKeys.set(section, key);
|
||||
const owner = savedStations.find(
|
||||
(station) => station.key === key && (station.kind === "pipe" || station.kind === "structure"),
|
||||
);
|
||||
if (owner && !byKey[key]) put(key, section, owner.chainageM);
|
||||
}
|
||||
if (routeId !== null)
|
||||
for (const [key, entry] of Object.entries(sessionCarried(projectId, routeId)))
|
||||
byKey[key] = { ...byKey[key], ...entry };
|
||||
return {
|
||||
// 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p)
|
||||
// 제원 지문 — 보기가 나중에 얹은 시설 제원도 입력 차이로(고리가 다시 셈 · 들어온 화면 무관 같은 면적 · 365-5)
|
||||
// 카드 번호 지문(365-5 · 시설 그림이 바뀌면 관 · 구조물 입력이 cross 를 다시 돌림 · 457)
|
||||
input: {
|
||||
standard,
|
||||
byKey,
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
* route · ground `B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts`(sub8)
|
||||
* follow · planline · profile `B05_Profile/logic/B05_Profile_Pipeline_Grade.ts`
|
||||
* stations `design_pipeline_stations.ts` · footprint `B05_Profile_Footprint_Stage.ts`(sub6)
|
||||
* cross `B06_Section_Cross_Stage` · drainage 배수유역 판 `pipelineStage`(sub3) · corridor 3D 「낡음」.
|
||||
* corridor 는 노선 초안 동안만 · 유토곡선 · 흙 배분은 유토곡선 판이 셈(454).
|
||||
* cross `B06_Section_Cross_Stage` · drainage 배수유역 판(sub3) · corridor 3D 「낡음」(초안 동안만) · 유토(454).
|
||||
*
|
||||
* 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집
|
||||
* (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점
|
||||
@@ -57,7 +56,7 @@ import {
|
||||
readAlignmentDraft,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import { plusStationsOf } from "../../B05_Profile/B05_Profile_Api_PlusStations";
|
||||
import { followPlusDraft, plusStationsOf } from "../../B05_Profile/B05_Profile_Api_PlusStations";
|
||||
import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History";
|
||||
import {
|
||||
analysisTerrain,
|
||||
@@ -86,9 +85,9 @@ import {
|
||||
routeRange,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
import {
|
||||
crossWithBerm,
|
||||
crossWithUphill,
|
||||
crossWithContext,
|
||||
draftSectionDetail,
|
||||
shapeStatus,
|
||||
type CrossInput,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import {
|
||||
@@ -227,9 +226,7 @@ function createPagePipeline(
|
||||
let dropPending = false;
|
||||
/** 노선 초안 전 가게 관 · 구조물 — 초안 동안 follow 가 새 측점을 가게에 씀 · 초안을 버리면 이것으로. */
|
||||
let beforeDraft: Facilities | null = null;
|
||||
/** 묶음이 가게에 마지막으로 쓴 관 · 구조물 · 쓰는 동안(메아리 — 다시 입력으로 안 받음). */
|
||||
let written: Facilities | null = null;
|
||||
let echo = false;
|
||||
let echo = false; // 묶음이 가게에 쓰는 동안(메아리 — 다시 입력으로 안 받음)
|
||||
/** 되돌리기 · 다시하기로 바뀐 노선(356-4) — follow 가 계획고 편집을 옮기지 않고 그 걸음 사진(세션 노선 0 칸)
|
||||
* 그대로. 바뀐 구간 첫 계획선 맞춤 · 계획고 옮김은 새 조작 때만. */
|
||||
let replay = false;
|
||||
@@ -268,10 +265,13 @@ function createPagePipeline(
|
||||
canon?.detail ?? null,
|
||||
savedStations,
|
||||
typeof routeId === "number" ? routeId : null,
|
||||
options.stationKeys, // 초안 자리 측점 짝(457)
|
||||
);
|
||||
if (source) crossSource = source;
|
||||
return source;
|
||||
};
|
||||
const statusOf = (pipes: unknown, structures: unknown) =>
|
||||
shapeStatus(pipes ?? savedPipes, structures ?? savedStructures, types); // 새 측점 그림 · 맥락(457)
|
||||
const groundDeps = groundStageDeps(projectId);
|
||||
const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j)
|
||||
const followStage = createFollowStage({
|
||||
@@ -352,9 +352,10 @@ function createPagePipeline(
|
||||
prev: ctx.prev && savedOut.has(ctx.prev) ? null : ctx.prev,
|
||||
inputs: {
|
||||
...ctx.inputs,
|
||||
cross: crossWithBerm(
|
||||
crossWithUphill(ctx.inputs.cross as CrossInput | null, stations),
|
||||
cross: crossWithContext(
|
||||
ctx.inputs.cross as CrossInput | null,
|
||||
stations,
|
||||
statusOf(ctx.inputs.pipes, ctx.inputs.structures),
|
||||
options.bermAt?.(ctx.inputs.structures) ?? null,
|
||||
),
|
||||
},
|
||||
@@ -449,6 +450,7 @@ function createPagePipeline(
|
||||
queueMicrotask(() => {
|
||||
if (event.generation !== pipe.generation) return;
|
||||
if (!pipe.input("route")) {
|
||||
followPlusDraft(projectId, null); // 추가 측점도 초안 전 값으로(459)
|
||||
if (!beforeDraft) return;
|
||||
writeFacilities(beforeDraft);
|
||||
beforeDraft = null;
|
||||
@@ -460,6 +462,7 @@ function createPagePipeline(
|
||||
pipes: (patch.pipes as Facilities["pipes"]) ?? now.pipes,
|
||||
structures: (patch.structures as Facilities["structures"]) ?? now.structures,
|
||||
});
|
||||
followPlusDraft(projectId, patch.plusStations); // 추가 측점 = 좌표 따라감(459)
|
||||
const line = pipe.output("route")?.line ?? [];
|
||||
const far = Array.isArray(patch.structures)
|
||||
? followStructures(
|
||||
@@ -485,6 +488,7 @@ function createPagePipeline(
|
||||
pipe.output("cross"),
|
||||
crossSource?.specs ?? new Map(),
|
||||
options.stationIntervalM ?? profile.policy.station_interval_m,
|
||||
statusOf(pipe.input("pipes"), pipe.input("structures")),
|
||||
)
|
||||
: null;
|
||||
if (shown) drafted.add(shown);
|
||||
@@ -509,17 +513,15 @@ function createPagePipeline(
|
||||
function facilitiesNow(): Facilities {
|
||||
return { pipes: store.get("pipes"), structures: store.get("structures") };
|
||||
}
|
||||
/** 가게에 씀 — 앞에 쓴 것과 같으면 안 씀(되돌리기가 세션을 되쓴 뒤에도 듣는 쪽은 다시 부름). */
|
||||
/** 가게에 씀 — 지금 가게 값과 같으면 안 씀(되돌리기가 가게를 되쓴 뒤 같은 조작도 씀 · 457). */
|
||||
function writeFacilities(next: Facilities): void {
|
||||
echo = true;
|
||||
try {
|
||||
if (!written || !same(written.pipes, next.pipes)) store.set("pipes", next.pipes);
|
||||
if (!written || !same(written.structures, next.structures))
|
||||
store.set("structures", next.structures);
|
||||
if (!same(store.get("pipes"), next.pipes)) store.set("pipes", next.pipes);
|
||||
if (!same(store.get("structures"), next.structures)) store.set("structures", next.structures);
|
||||
} finally {
|
||||
echo = false;
|
||||
}
|
||||
written = next;
|
||||
}
|
||||
|
||||
const inputsNow = (): PipelineInputs => {
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface PagePipelineOptions {
|
||||
/** 초안 전 가게 관 · 구조물(초안 둔 채 새로고침도 · 440-4) — 묶음이 초안을 버릴 때 되씀. */
|
||||
seed?: (facilities: Pick<DesignStoreValues, "pipes" | "structures">) => void,
|
||||
) => (stations: PipelineStation[]) => Promise<string>;
|
||||
/** 저장 상세 측점마다 등록 열쇠(B06 측점 열쇠 목록) — 초안 자리로 뜬 시설 측점의 입력 짝(457). */
|
||||
stationKeys?: (routeId: number, detail: SectionDetailResponse) => string[];
|
||||
/** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */
|
||||
onUnstable?: () => void;
|
||||
/** 소단 구간(지금 구조물 목록 → 누가거리마다 제원) — 초안 cross 가 따라간 구조물로 소단을 셈(365-5). */
|
||||
|
||||
@@ -19,9 +19,12 @@ import { probeStation } from "./logic/design_pipeline_probe_station";
|
||||
import { groundStageDeps } from "../../B05_Profile/B05_Profile_Api_TerrainGrid";
|
||||
import { createGroundStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
|
||||
import {
|
||||
crossWithShapes,
|
||||
crossWithUphill,
|
||||
shapeStatus,
|
||||
type CrossInput,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage";
|
||||
import { pipelineCrossAt } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Cross";
|
||||
import type { CrossPreviewResponse } from "../../B05_Profile/B05_Profile_Api_Replan";
|
||||
@@ -72,6 +75,13 @@ export async function pipelineCrossAtChainage(
|
||||
station = filled.stations[0] ?? null;
|
||||
}
|
||||
if (!station) return null;
|
||||
// 시설 맥락 = 지금 현황을 그 측점에 얹은 그림에서(묶음 cross 와 같음 · 457)
|
||||
const status = shapeStatus(
|
||||
inputs.pipes,
|
||||
inputs.structures,
|
||||
await fetchStructureTypes(true).catch(() => []),
|
||||
);
|
||||
const shaped = crossWithShapes(inputs.cross as CrossInput | null, [station], status);
|
||||
const out = {
|
||||
profile,
|
||||
ground: { stations: [station], longitudinal: { chainageM: [], z: [] } },
|
||||
@@ -80,7 +90,7 @@ export async function pipelineCrossAtChainage(
|
||||
generation: 0,
|
||||
range: null,
|
||||
signal: new AbortController().signal,
|
||||
inputs: { ...inputs, cross: crossWithUphill(inputs.cross as CrossInput | null, [station]) },
|
||||
inputs: { ...inputs, cross: crossWithUphill(shaped, [station]) },
|
||||
out,
|
||||
before: {},
|
||||
prev: null,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type ChartWindow,
|
||||
type FreeDraw,
|
||||
} from "./logic/design_profile_free";
|
||||
import { appendRangeBand } from "./design_profile_range_band";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -100,29 +101,10 @@ export function createFreeView(free: FreeDraw): FreeView {
|
||||
height: heightPx,
|
||||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||||
}) as SVGSVGElement;
|
||||
if (marks.band) {
|
||||
const [left, right] = marks.band.map(x);
|
||||
svg.append(
|
||||
svgEl("rect", {
|
||||
class: "b05-profile-freemark__band",
|
||||
x: left,
|
||||
y: 0,
|
||||
width: Math.max(0, right - left),
|
||||
height: heightPx,
|
||||
}),
|
||||
);
|
||||
// 범위 띠 · 세로선 · 길이 글 = [직선화]와 한 벌(271-3) — 고르는 중엔 마우스 측점까지
|
||||
if (marks.edges.length) {
|
||||
appendRangeBand(svg, x, heightPx, marks.edges[0], marks.edges[1] ?? null);
|
||||
}
|
||||
marks.edges.forEach((chainage) =>
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
class: "b05-profile-freemark__edge",
|
||||
x1: x(chainage),
|
||||
x2: x(chainage),
|
||||
y1: 0,
|
||||
y2: heightPx,
|
||||
}),
|
||||
),
|
||||
);
|
||||
marks.segments.forEach((segment) => {
|
||||
const x1 = x(segment.from.chainage_m);
|
||||
const x2 = x(segment.to.chainage_m);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile_range_band.ts
|
||||
* 종단 범위 실시간 띠 — 시작 세로선을 고른 뒤 마우스 측점까지 띠 · 세로선 · 「시작–끝 · 길이」 글(271-2 · 3).
|
||||
*
|
||||
* [직선화]는 `createRangeBandView` 로 그래프에 붙이고, [자유지정]은 `design_profile_free_view` 가
|
||||
* 같은 `appendRangeBand` 로 그린다 — 띠 한 벌. 마우스 자리는 가장 가까운 측점선에 붙음 · 그래프 밖 =
|
||||
* 시작선만 · 다시 들어오면 이어짐. 색 · 굵기는 자유지정 범위 띠(`b05-profile-freemark__*`)와 같음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { snapStation } from "./logic/design_profile_free";
|
||||
import type { FreeChartGeom } from "./design_profile_free_view";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function svgEl(tag: string, attrs: Record<string, string | number>): SVGElement {
|
||||
const element = document.createElementNS(SVG_NS, tag);
|
||||
Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value)));
|
||||
return element;
|
||||
}
|
||||
|
||||
/** 범위 띠를 붙인다 — 끝이 없거나 시작과 같으면 시작선만 · 반대 방향도 같은 띠. */
|
||||
export function appendRangeBand(
|
||||
svg: SVGElement,
|
||||
x: (chainageM: number) => number,
|
||||
heightPx: number,
|
||||
startM: number,
|
||||
endM: number | null,
|
||||
): void {
|
||||
const ends =
|
||||
endM === null || Math.abs(endM - startM) < 1e-6
|
||||
? [startM]
|
||||
: [Math.min(startM, endM), Math.max(startM, endM)];
|
||||
if (ends.length === 2) {
|
||||
const [left, right] = ends.map(x);
|
||||
svg.append(
|
||||
svgEl("rect", {
|
||||
class: "b05-profile-freemark__band",
|
||||
x: left,
|
||||
y: 0,
|
||||
width: Math.max(0, right - left),
|
||||
height: heightPx,
|
||||
}),
|
||||
);
|
||||
const label = svgEl("text", {
|
||||
class: "b05-profile-freemark__span",
|
||||
x: (left + right) / 2,
|
||||
y: 14,
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
label.textContent = `${ends[0].toFixed(1)}–${ends[1].toFixed(1)}m · ${(ends[1] - ends[0]).toFixed(1)}m`;
|
||||
svg.append(label);
|
||||
}
|
||||
ends.forEach((chainage) =>
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
class: "b05-profile-freemark__edge",
|
||||
x1: x(chainage),
|
||||
x2: x(chainage),
|
||||
y1: 0,
|
||||
y2: heightPx,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RangeBandView {
|
||||
/** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */
|
||||
mount: (geom: FreeChartGeom | null, active: boolean) => void;
|
||||
redraw: () => void;
|
||||
}
|
||||
|
||||
/** 범위 띠 화면 — `source(마우스 측점)` 가 [시작, 끝 | null] 을 주면 그림 · null 이면 지움. */
|
||||
export function createRangeBandView(
|
||||
source: (hoverM: number | null) => [number, number | null] | null,
|
||||
): RangeBandView {
|
||||
let geom: FreeChartGeom | null = null;
|
||||
let active = false;
|
||||
let hover: number | null = null;
|
||||
/** 마지막 마우스 x(화면) — 스크롤 · 다시 그림 뒤에도 커서 밑 측점을 다시 잡는다. */
|
||||
let clientX: number | null = null;
|
||||
let overlay: SVGElement | null = null;
|
||||
let listeners: AbortController | null = null;
|
||||
|
||||
function redraw(): void {
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
const range = geom && active ? source(hover) : null;
|
||||
if (!geom || !range) return;
|
||||
const { widthPx, heightPx } = geom;
|
||||
overlay = svgEl("svg", {
|
||||
class: "b05-profile-freemark",
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||||
});
|
||||
appendRangeBand(overlay, geom.x, heightPx, range[0], range[1]);
|
||||
geom.chart.append(overlay);
|
||||
}
|
||||
|
||||
/** 커서 밑 측점을 다시 잡는다 — 바뀌었을 때만 다시 그림. */
|
||||
function track(): void {
|
||||
let next: number | null = null;
|
||||
if (geom && clientX !== null) {
|
||||
const at = geom.toChainage(clientX - geom.chart.getBoundingClientRect().left);
|
||||
next = Number.isFinite(at) ? snapStation(geom.stations, at) : null;
|
||||
}
|
||||
if (next === hover) return;
|
||||
hover = next;
|
||||
redraw();
|
||||
}
|
||||
|
||||
function mount(next: FreeChartGeom | null, on: boolean): void {
|
||||
listeners?.abort();
|
||||
listeners = null;
|
||||
if (next) geom = next;
|
||||
active = on;
|
||||
if (!on) [hover, clientX] = [null, null];
|
||||
if (!geom || !on || !geom.chart.isConnected) {
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
return;
|
||||
}
|
||||
listeners = new AbortController();
|
||||
const { signal } = listeners;
|
||||
const chart = geom.chart;
|
||||
chart.addEventListener(
|
||||
"mousemove",
|
||||
(event) => {
|
||||
clientX = event.clientX;
|
||||
track();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
chart.addEventListener(
|
||||
"mouseleave",
|
||||
() => {
|
||||
clientX = null;
|
||||
track();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
geom.scroller.addEventListener("scroll", track, { signal, passive: true });
|
||||
hover = null;
|
||||
track();
|
||||
redraw();
|
||||
}
|
||||
|
||||
return { mount, redraw };
|
||||
}
|
||||
@@ -29,8 +29,13 @@ import {
|
||||
writePendingPipes,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { rebasePipeDraft } from "../../common_util/common_util_route_follow";
|
||||
import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { rebasePipeDraft, rebaseStructureDraft } from "../../common_util/common_util_route_follow";
|
||||
import {
|
||||
fetchStructures,
|
||||
flushPendingStructures,
|
||||
readPendingStructures,
|
||||
writePendingStructures,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { flushPlusStations } from "../../B05_Profile/B05_Profile_Api_PlusStations";
|
||||
import {
|
||||
flushUphillOverrides,
|
||||
@@ -209,6 +214,25 @@ async function rebasePendingPipes(projectId: string, before: SavedPipes): Promis
|
||||
writePendingPipes(projectId, rebasePipeDraft(draft, before, moved));
|
||||
}
|
||||
|
||||
type SavedStructures = Awaited<ReturnType<typeof fetchStructures>>["structures"];
|
||||
|
||||
/** 세션 구조물 초안이 있으면 노선 저장 전 정본 — 없으면 · 못 받으면 null(얹기 안 함 · 459). */
|
||||
const savedStructuresIfDraft = (projectId: string): Promise<SavedStructures | null> =>
|
||||
readPendingStructures(projectId)
|
||||
? fetchStructures(projectId)
|
||||
.then((list) => list.structures)
|
||||
.catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
|
||||
/** 노선을 저장함 — 서버가 구조물을 좌표로 따라 옮김. 세션 구조물 초안을 그 위에 얹음(459 · 관 짝) — 구조물
|
||||
* 차례가 서버 따라감을 브라우저 누가거리로 덮지 않게. 못 받으면 초안 그대로. */
|
||||
async function rebasePendingStructures(projectId: string, before: SavedStructures): Promise<void> {
|
||||
const draft = readPendingStructures(projectId);
|
||||
const after = await fetchStructures(projectId).catch(() => null);
|
||||
if (!draft || !after) return;
|
||||
writePendingStructures(projectId, rebaseStructureDraft(draft, before, after.structures));
|
||||
}
|
||||
|
||||
/**
|
||||
* [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다.
|
||||
* 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true.
|
||||
@@ -234,10 +258,19 @@ export async function saveDesign(
|
||||
if (step === "route") {
|
||||
const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄
|
||||
if (!body) continue;
|
||||
const before = await savedPipesIfDraft(ctx.projectId);
|
||||
const [before, beforeStructures] = await Promise.all([
|
||||
savedPipesIfDraft(ctx.projectId),
|
||||
savedStructuresIfDraft(ctx.projectId),
|
||||
]);
|
||||
routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림)
|
||||
if (routeSaved.route_id != null) routeId = routeSaved.route_id;
|
||||
if (before) await rebasePendingPipes(ctx.projectId, before);
|
||||
if (beforeStructures) await rebasePendingStructures(ctx.projectId, beforeStructures);
|
||||
if (routeSaved.follow_failed?.length)
|
||||
showToast(
|
||||
`${routeSaved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`,
|
||||
"error",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (step === "cross") {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type CrossRefreshInput,
|
||||
} from "../../B06_Section/B06_Section_Cross_Refresh";
|
||||
import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
import { registeredKeys, stationIntervalOf } from "../../B06_Section/B06_Section_Station_Keys";
|
||||
import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import type { CrossOutput, PipelineStation } from "./logic/design_pipeline_types";
|
||||
|
||||
@@ -245,6 +246,9 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
|
||||
},
|
||||
),
|
||||
plantedBase, // 저장 노선 종단 기준 = 입력이 심는 측점(386-3 · 보기 심기 무관)
|
||||
// 저장 상세 측점마다 등록 열쇠 — 초안 자리로 뜬 시설 측점의 입력 짝(주인 id · 457)
|
||||
stationKeys: (routeId, detail) =>
|
||||
registeredKeys(projectId, routeId, detail.cross_sections, stationIntervalOf(detail)),
|
||||
structureAreas: (detail) => {
|
||||
// 초안 상세에도 저장 안 한 조절(옹벽 · 세월 · BOX · 집수 · 417 M3) — 면적 얹기 앞
|
||||
const routeId = routeIdOf(shared);
|
||||
|
||||
@@ -60,7 +60,12 @@ export const STAGE_DEPS: Record<StageName, Dep[]> = {
|
||||
};
|
||||
|
||||
/** follow 가 고칠 수 있는 입력 — 노선 · 횡단은 안 됨(고리). */
|
||||
const FOLLOW_KEYS: ReadonlyArray<keyof FollowPatch> = ["pipes", "structures", "alignment"];
|
||||
const FOLLOW_KEYS: ReadonlyArray<keyof FollowPatch> = [
|
||||
"pipes",
|
||||
"structures",
|
||||
"alignment",
|
||||
"plusStations",
|
||||
];
|
||||
/** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단이 그다음. */
|
||||
const YIELD_AFTER = new Set<StageName>(["profile"]);
|
||||
export const RUN_LOG_SIZE = 20;
|
||||
|
||||
@@ -74,7 +74,12 @@ export function createStationsStage(
|
||||
route.curves,
|
||||
(ctx.inputs.pipes as PlanPipe[] | null) ?? [],
|
||||
(ctx.inputs.structures as PlanStructure[] | null) ?? [],
|
||||
{ ...rest, types: options.types(), ...snapOptions(ctx.inputs.route, saved?.()) },
|
||||
{
|
||||
...rest,
|
||||
types: options.types(),
|
||||
...snapOptions(ctx.inputs.route, saved?.()),
|
||||
plus: ctx.inputs.plusStations ?? undefined, // follow 가 옮긴 추가 측점(459 · 가게보다 먼저)
|
||||
},
|
||||
);
|
||||
return reuseOutside(next, ctx.prev, ctx.range);
|
||||
};
|
||||
|
||||
@@ -152,6 +152,8 @@ export interface FollowPatch {
|
||||
pipes?: unknown;
|
||||
structures?: unknown;
|
||||
alignment?: AlignmentEdits;
|
||||
/** 추가 측점 — 새 노선 정수 m(459 · 좌표 따라감). */
|
||||
plusStations?: ReadonlyArray<number> | null;
|
||||
}
|
||||
|
||||
/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점) · 곡선(MC · 252-2 · 격자 번호 없음). */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
|
||||
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) =
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소(범위 첫 측점만 = 시작만 · 271-3) · ↶ ↷(Backspace = ↶) =
|
||||
* 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음.
|
||||
*
|
||||
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
baseElevationAt,
|
||||
defaultCurveLength,
|
||||
dropAutoEndRadii,
|
||||
pinEnds,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
|
||||
import { isStraightNode } from "../../../B05_Profile/logic/B05_Profile_Profile_Straight";
|
||||
@@ -120,12 +121,14 @@ function elevationOnPath(path: FreeVertex[], chainageM: number): number {
|
||||
|
||||
/**
|
||||
* 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를
|
||||
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음).
|
||||
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음) ·
|
||||
* 끝 R 이 저장 자동 R 이면 지워 기본 L(271-1).
|
||||
*/
|
||||
export function buildFreeEdits(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
vertices: FreeVertex[],
|
||||
autoRadii: Readonly<Record<string, number>> = {},
|
||||
): AlignmentEdits {
|
||||
const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
if (path.length < 2) return edits;
|
||||
@@ -148,6 +151,7 @@ export function buildFreeEdits(
|
||||
pinEnds(base, current, [fromM, toM]).forEach(([key, value]) => {
|
||||
offsets[key] = Number(value.toFixed(6));
|
||||
});
|
||||
dropAutoEndRadii(radii, [fromM, toM], autoRadii);
|
||||
inner.forEach((chainage, key) => {
|
||||
const target = elevationOnPath(path, chainage);
|
||||
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
|
||||
@@ -191,6 +195,8 @@ export interface FreeDrawDeps {
|
||||
edits: () => AlignmentEdits;
|
||||
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */
|
||||
apply: (next: AlignmentEdits) => boolean | void;
|
||||
/** 저장 자동 R — 끝 R 이 이것과 같으면 지움(271-1). */
|
||||
autoRadii?: () => Readonly<Record<string, number>>;
|
||||
/** 모드를 끈다(적용 뒤 · Esc). */
|
||||
close: () => void;
|
||||
/** 단계 · 안내가 바뀜. */
|
||||
@@ -222,6 +228,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
let anchor: number | null = null;
|
||||
let points: FreeVertex[] = [];
|
||||
let target: FreeVertex | null = null;
|
||||
/** range 단계 첫 측점 뒤 마우스 측점 — 실시간 띠 끝(271-3). */
|
||||
let hoverStation: number | null = null;
|
||||
/** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */
|
||||
interface Snap {
|
||||
phase: FreePhase;
|
||||
@@ -249,6 +257,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
anchor = null;
|
||||
points = [];
|
||||
target = null;
|
||||
hoverStation = null;
|
||||
past = [];
|
||||
future = [];
|
||||
}
|
||||
@@ -296,7 +305,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
const z = end === null ? null : heightAt(end);
|
||||
if (!base || end === null || z === null) return;
|
||||
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
|
||||
const next = buildFreeEdits(base, deps.edits(), vertices);
|
||||
const next = buildFreeEdits(base, deps.edits(), vertices, deps.autoRadii?.());
|
||||
// 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3)
|
||||
if (deps.apply(next) === false) return;
|
||||
reset();
|
||||
@@ -343,8 +352,19 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
},
|
||||
hover(pointer) {
|
||||
target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null;
|
||||
hoverStation =
|
||||
pointer && phase === "range" && pending !== null
|
||||
? snapStation(pointer.stations, pointer.chainageM)
|
||||
: null;
|
||||
},
|
||||
key(key) {
|
||||
// range 단계 첫 측점만 고른 채 Esc = 시작만 취소 · 모드 유지(271-3)
|
||||
if (key === "Escape" && phase === "range" && pending !== null) {
|
||||
pending = null;
|
||||
hoverStation = null;
|
||||
deps.changed();
|
||||
return true;
|
||||
}
|
||||
if (key === "Escape") {
|
||||
reset();
|
||||
deps.close();
|
||||
@@ -381,9 +401,15 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
preview: target !== null && index === line.length - 1,
|
||||
});
|
||||
}
|
||||
// 범위 고르는 중엔 첫 측점 → 마우스 측점까지 띠(271-3) · 둘째 클릭 뒤엔 고른 범위로 고정
|
||||
const live =
|
||||
pending !== null && hoverStation !== null && !same(pending, hoverStation)
|
||||
? ([Math.min(pending, hoverStation), Math.max(pending, hoverStation)] as [number, number])
|
||||
: null;
|
||||
const band = range ?? live;
|
||||
return {
|
||||
band: range,
|
||||
edges: range ? [...range] : pending !== null ? [pending] : [],
|
||||
band,
|
||||
edges: band ? [...band] : pending !== null ? [pending] : [],
|
||||
segments,
|
||||
dots,
|
||||
};
|
||||
@@ -392,7 +418,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
if (phase === "range") {
|
||||
return pending === null
|
||||
? "자유지정 — 범위 첫 세로 측점선을 고르세요"
|
||||
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
|
||||
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요 (Esc 시작 취소)`;
|
||||
}
|
||||
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
|
||||
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)";
|
||||
|
||||
@@ -70,8 +70,43 @@ export function loadSavedPlusStations(projectId: string): Promise<void> {
|
||||
return loaded.get(projectId)!;
|
||||
}
|
||||
|
||||
/** [저장] 차례 — 초안이 있으면 정본에 씀 · 없으면 저장분만 다시 받음(노선 저장이 좌표로 옮겼을 수 있음). */
|
||||
/** 노선 초안 전 추가 측점 초안(없으면 null) — 초안을 버리면 이것으로(459). */
|
||||
const beforeRoute = new Map<string, number[] | null>();
|
||||
/** follow 만 쓴 초안(사용자 초안 없던 때) — 그대로면 [저장]이 PUT 안 함(서버 따라감이 정본 · 459). */
|
||||
const followedOnly = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* 노선 초안 동안 추가 측점 따라감(459) — 묶음 follow 출력(`plusStations` · 새 노선 정수 m)을 초안에 씀 ·
|
||||
* `next = null` = 노선 초안을 버림(초안 전 값으로). 부르는 곳 = design_pipeline_page follow 구독
|
||||
* (관 · 구조물 `writeFacilities` 곁 · 초안 버림 갈래도).
|
||||
*/
|
||||
export function followPlusDraft(
|
||||
projectId: string,
|
||||
next: ReadonlyArray<number> | null | undefined,
|
||||
): void {
|
||||
if (next === null) {
|
||||
if (!beforeRoute.has(projectId)) return;
|
||||
writePlusDraft(projectId, beforeRoute.get(projectId)!);
|
||||
beforeRoute.delete(projectId);
|
||||
followedOnly.delete(projectId);
|
||||
return;
|
||||
}
|
||||
if (!next) return;
|
||||
if (!beforeRoute.has(projectId)) beforeRoute.set(projectId, readPlusDraft(projectId));
|
||||
writePlusDraft(projectId, [...next]);
|
||||
if (beforeRoute.get(projectId)) followedOnly.delete(projectId);
|
||||
else followedOnly.set(projectId, JSON.stringify(readPlusDraft(projectId)));
|
||||
}
|
||||
|
||||
/** [저장] 차례 — 사용자 초안이 있으면 정본에 씀 · 없으면(follow 만 쓴 초안 포함 · 459) 저장분만 다시 받음
|
||||
* (노선 저장이 좌표로 옮겼을 수 있음). */
|
||||
export async function flushPlusStations(projectId: string): Promise<void> {
|
||||
const auto = followedOnly.get(projectId);
|
||||
followedOnly.delete(projectId);
|
||||
beforeRoute.delete(projectId);
|
||||
const kept = readPlusDraft(projectId);
|
||||
if (kept && auto === JSON.stringify(kept))
|
||||
useDesignStore(projectId, null).set("plusStations", null);
|
||||
const draft = readPlusDraft(projectId);
|
||||
const list = draft
|
||||
? await request(projectId, { method: "PUT", body: JSON.stringify({ chainages: draft }) })
|
||||
|
||||
@@ -135,6 +135,8 @@ export interface RouteReplanResponse {
|
||||
/** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차. */
|
||||
pipes?: { kept: number; crossing: number };
|
||||
structures?: { moved: number; far: number };
|
||||
/** 따라감 실패한 것(「구조물」 · 「추가 측점」) — 옛 누가거리 그대로(459). */
|
||||
follow_failed?: string[];
|
||||
/** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */
|
||||
cross_dropped?: number;
|
||||
/** 배수유역 저장분이 새 노선과 안 맞음(다시 안 셈). */
|
||||
|
||||
@@ -18,6 +18,8 @@ from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
|
||||
|
||||
# 초기값 직선 문턱 R(m) — TS `INITIAL_STRAIGHT_RADIUS_M` 과 같은 값.
|
||||
INITIAL_STRAIGHT_RADIUS_M = float(FOREST_ROAD_PROFILE_ALIGNMENT["initial_straight_radius_m"])
|
||||
# 법정 미달 판정 오차(m) — R = L/Δ 되셈 L 19.99998 거짓 미달 막음(271-1) · TS `SHORTFALL_TOLERANCE_M` 짝.
|
||||
SHORTFALL_TOLERANCE_M = 1e-3
|
||||
|
||||
|
||||
def flat_bends(base_s: np.ndarray, base_z: np.ndarray, curve_length_m: float | None) -> np.ndarray:
|
||||
@@ -60,7 +62,10 @@ def vertical_curve_shortfalls(
|
||||
continue
|
||||
radius = float(curve.get("radius_m", curve.get("r_m", 0.0)))
|
||||
length = float(curve.get("length_m", curve.get("l_m", 0.0)))
|
||||
if radius < min_radius_m - 1e-6 or length < min_length_m - 1e-6:
|
||||
if (
|
||||
radius < min_radius_m - SHORTFALL_TOLERANCE_M
|
||||
or length < min_length_m - SHORTFALL_TOLERANCE_M
|
||||
):
|
||||
out.append(
|
||||
f"{float(curve['chainage_m']):.1f}m: 종단곡선 R {radius:.0f}m · L {length:.1f}m — "
|
||||
f"법정 최소 R {min_radius_m:.0f}m · L {min_length_m:.0f}m 미달"
|
||||
|
||||
@@ -45,6 +45,8 @@ from B05_Profile.B05_Profile_Route_Save_Parts import (
|
||||
add_crossing_pipes,
|
||||
carry_cross_inputs,
|
||||
follow_pipes,
|
||||
route_moved_stations,
|
||||
seat_route_moved,
|
||||
station_keys,
|
||||
vertices_of,
|
||||
)
|
||||
@@ -168,16 +170,24 @@ async def _place_pipes(
|
||||
old_xy: list[list[float]],
|
||||
points: list[dict[str, float]],
|
||||
) -> tuple[list, dict[str, int]]:
|
||||
"""관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369)."""
|
||||
"""관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369).
|
||||
|
||||
따라갈 선 = 구조물 · 추가 측점과 같은 `points`(사용 범위 자름 · 459) — B04 `_prepare`
|
||||
노선은 범위를 안 잘라 누가거리가 갈림 · 세류선만 거기서. 좌표 없는 옛 관은 옛 노선
|
||||
자리로 먼저 채움(459).
|
||||
"""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare
|
||||
from common_util.common_util_drainage_pipes import route_signature, save_pipe_points
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
fill_pipe_coordinates,
|
||||
route_signature,
|
||||
save_pipe_points,
|
||||
)
|
||||
|
||||
prepared = await _prepare(project_id)
|
||||
if isinstance(prepared, JSONResponse): # 도엽 · 노선 파일 없음 — 따라감만
|
||||
vertices, streams = vertices_of([(p["x"], p["y"]) for p in points]), []
|
||||
else:
|
||||
vertices, streams = prepared["vertices"], prepared["streams"]
|
||||
streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"] # 도엽 없음
|
||||
vertices = vertices_of([(p["x"], p["y"]) for p in points])
|
||||
fill_pipe_coordinates(old_pipes, vertices_of(old_xy))
|
||||
pairs = follow_pipes(old_pipes, vertices)
|
||||
total = float(vertices[-1].chainage_m)
|
||||
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total)
|
||||
@@ -226,6 +236,10 @@ async def _carry_profile(
|
||||
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)
|
||||
await asyncio.to_thread(atomic_write_json, path, longitudinal)
|
||||
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||
_apply_alignment_edits, project_root, relative, edits
|
||||
@@ -387,6 +401,7 @@ async def run_route_save_chain(
|
||||
)
|
||||
mark_designing(project_root)
|
||||
try:
|
||||
follow_failed: list[str] = [] # 따라감 실패 = 옛 누가거리 그대로 · 화면이 알림(459)
|
||||
# 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움)
|
||||
try:
|
||||
structures = await asyncio.to_thread(
|
||||
@@ -395,12 +410,14 @@ async def run_route_save_chain(
|
||||
except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명)
|
||||
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
|
||||
structures = {"moved": 0, "far": 0}
|
||||
follow_failed.append("구조물")
|
||||
try: # 추가 측점도 땅 위 자리 따라감(368-2 · 실패 = 옛 누가거리 그대로)
|
||||
await asyncio.to_thread(
|
||||
follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points]
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
|
||||
follow_failed.append("추가 측점")
|
||||
request = redesign_solve_request(params, selection, surface_model_id)
|
||||
solved: Any = await solve_route_record(project_id, request, generate_sections=False)
|
||||
if isinstance(solved, JSONResponse):
|
||||
@@ -481,6 +498,7 @@ async def run_route_save_chain(
|
||||
"total_length_m": float(solved.total_length_m or 0.0),
|
||||
"pipes": pipes,
|
||||
"structures": structures,
|
||||
"follow_failed": follow_failed,
|
||||
"cross_dropped": dropped,
|
||||
"drainage_stale": True,
|
||||
"seconds": round(marks[-1][1] - marks[0][1], 2),
|
||||
|
||||
@@ -186,3 +186,42 @@ def carry_cross_inputs(
|
||||
if adjust:
|
||||
adjusts[round(new_at, 3)] = adjust
|
||||
return inputs, adjusts, dropped
|
||||
|
||||
|
||||
def route_moved_stations(
|
||||
stations: Sequence[dict[str, Any]],
|
||||
old_xy: Sequence[Sequence[float]],
|
||||
new_xy: Sequence[Sequence[float]],
|
||||
) -> list[float]:
|
||||
"""노선 바뀜으로 옮긴 시설 측점 — 관 · 구조물 측점(`structure` 이름 있음) 중 옛 선과
|
||||
같은 자리가 아닌 것(`line_matcher` 짝 없음 · 459). 같은 자리로 따라간 시설은 뺌(옛 높이
|
||||
그대로). TS 짝 = `routeMovedStations`(B05_Profile/logic/B05_Profile_Pipeline_Grade.ts) ·
|
||||
거울 시험 = test_459_route_seat_mirror.py."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import line_matcher
|
||||
|
||||
match = line_matcher([tuple(p) for p in old_xy], [tuple(p) for p in new_xy])
|
||||
return sorted(
|
||||
float(station["chainage_m"])
|
||||
for station in stations
|
||||
if station.get("structure") and match(float(station["chainage_m"])) is None
|
||||
)
|
||||
|
||||
|
||||
def seat_route_moved(
|
||||
alignment: dict[str, Any], born: Sequence[float], held: Sequence[float] = ()
|
||||
) -> list[dict[str, Any]]:
|
||||
"""옮긴 시설 측점(`born`)에 427 규칙으로 꼭짓점을 다시 세움(459 · 요구 270) — 그 자리
|
||||
옛 꼭짓점(옮겨 붙은 지반 · 옛 높이)을 걷고 원지반 지남 · 이웃 1 mm 맞춤 · 균형 종단이면
|
||||
옮기기 전 계획선 높이(`_seat_born_nodes`). `alignment` = 새 지반 표본 · 정책 · 옮겨 붙인
|
||||
`base_pvi` · `held` = 높이 고친 측점. TS 짝 = `seatRouteMoved`."""
|
||||
from B05_Profile.B05_Profile_Router_Confirm import _SAME_STATION_M, _seat_born_nodes
|
||||
|
||||
base = alignment.get("base_pvi") or []
|
||||
if not born:
|
||||
return base
|
||||
kept = [
|
||||
node
|
||||
for node in base
|
||||
if all(abs(float(node["chainage_m"]) - value) >= _SAME_STATION_M for value in born)
|
||||
]
|
||||
return _seat_born_nodes(alignment, kept, list(born), list(held))
|
||||
|
||||
@@ -29,11 +29,13 @@ import {
|
||||
hasStraightRuns,
|
||||
shiftStraightRuns,
|
||||
straightenBetween,
|
||||
straightSubRun,
|
||||
tiltStraightRun,
|
||||
} from "./logic/B05_Profile_UI_Profile_Straighten";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import { createFreeDraw } from "../A00_Common/design/logic/design_profile_free";
|
||||
import { createFreeDraw, snapStation } from "../A00_Common/design/logic/design_profile_free";
|
||||
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
|
||||
import { createRangeBandView } from "../A00_Common/design/design_profile_range_band";
|
||||
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
|
||||
import { stepStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
|
||||
@@ -121,17 +123,35 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
alignment: ctx.alignment,
|
||||
edits: ctx.edits,
|
||||
apply: (next) => ctx.applyEdits(next),
|
||||
autoRadii: ctx.savedRadii,
|
||||
close: () => tools.clear(),
|
||||
changed: ctx.refresh,
|
||||
});
|
||||
const freeView = createFreeView(free);
|
||||
let lastGeom: FreeChartGeom | null = null;
|
||||
// [직선화] 실시간 띠(271-2) — 시작 측점 → 마우스 측점 · 고른 부분 구간은 고정 띠.
|
||||
const bandView = createRangeBandView((hoverM) => {
|
||||
const start = tools.pendingStation();
|
||||
const sub = tools.subRun();
|
||||
return start !== null ? [start, hoverM] : sub ? [sub.fromM, sub.toM] : null;
|
||||
});
|
||||
const mountViews = (mode: string): void => {
|
||||
freeView.mount(lastGeom, mode === "free");
|
||||
bandView.mount(lastGeom, mode === "straighten");
|
||||
};
|
||||
|
||||
const tools = createProfileTools({
|
||||
onStraighten: (fromM, toM) => {
|
||||
const base = ctx.base();
|
||||
if (!base) return;
|
||||
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM));
|
||||
// 이미 직선인 범위 = 부분 구간으로 고름(271-1) — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선
|
||||
const alignment = ctx.alignment();
|
||||
const sub = alignment ? straightSubRun(alignment, fromM, toM) : null;
|
||||
if (sub) {
|
||||
tools.handleRunPick(sub);
|
||||
return;
|
||||
}
|
||||
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii()));
|
||||
tools.clear();
|
||||
},
|
||||
onShift: (runs, delta) => {
|
||||
@@ -159,12 +179,12 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
const alignment = ctx.alignment();
|
||||
return alignment ? hasStraightRuns(alignment) : false;
|
||||
},
|
||||
onChanged: ctx.refresh,
|
||||
onModeChange: (mode) => freeView.mount(lastGeom, mode === "free"),
|
||||
onChanged: () => (bandView.redraw(), ctx.refresh()),
|
||||
onModeChange: mountViews,
|
||||
freeHint: () => free.hint(),
|
||||
mountOverlay: (geom) => {
|
||||
lastGeom = geom;
|
||||
freeView.mount(geom, tools.mode() === "free");
|
||||
mountViews(tools.mode());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -178,15 +198,10 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
if (mode === "shift") {
|
||||
return tools.handleRunPick(alignment ? detectStraightRun(alignment, chainageM) : null);
|
||||
}
|
||||
// 직선화 모드에서 첫 클릭이 **이미 직선화된 라인 안쪽**이면 그 라인을 골라
|
||||
// 틸팅(가운데 라운드 + 양측 탄젠트) 대상으로 삼는다.
|
||||
if (tools.pendingStation() === null && alignment) {
|
||||
const run = detectStraightRun(alignment, chainageM);
|
||||
if (run && Math.abs(run.fromM - chainageM) > 1e-6 && Math.abs(run.toM - chainageM) > 1e-6) {
|
||||
return tools.handleRunPick(run);
|
||||
}
|
||||
}
|
||||
return tools.handleStationPick(chainageM);
|
||||
// 직선화 클릭 = 늘 측점 고름 · 빈 곳은 가장 가까운 측점선에 붙음(271-1) — 직선 안 클릭이
|
||||
// 직선 통째 고름(붉은 강조)으로 새던 갈림을 지움. 이미 직선인 범위는 `onStraighten` 이 가름.
|
||||
const station = lastGeom ? snapStation(lastGeom.stations, chainageM) : null;
|
||||
return tools.handleStationPick(station ?? chainageM);
|
||||
}
|
||||
|
||||
function selectedIrregular(): IrregularStation | null {
|
||||
@@ -355,10 +370,33 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
/* Esc = 도구 몫(271-2 · 3) — 잡는 단계에서 먼저 받아 고름 풀기(`design_pick_escape`)로 안 샘:
|
||||
* [직선화] 시작 측점 · 부분 구간만 취소(모드 유지) · [자유지정]은 자기 규칙(범위 첫 측점만 = 시작만). */
|
||||
const onEscape = (event: KeyboardEvent): void => {
|
||||
if (event.key !== "Escape" || !ctx.root.isConnected) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (
|
||||
target?.closest?.("input, select, textarea") ||
|
||||
document.querySelector(".ui-modal__backdrop")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (tools.mode() === "free") {
|
||||
free.key("Escape");
|
||||
freeView.redraw();
|
||||
} else if (!(tools.mode() === "straighten" && tools.cancelPick())) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
window.addEventListener("keydown", onEscape, true);
|
||||
|
||||
const setListening = (on: boolean): void => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
if (on) window.addEventListener("keydown", onKeyDown);
|
||||
else stopHold();
|
||||
window.removeEventListener("keydown", onEscape, true);
|
||||
if (on) {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keydown", onEscape, true);
|
||||
} else stopHold();
|
||||
};
|
||||
return { tools, history, handleToolPick, setListening };
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
* [새 창] 과 같다(공용 토큰 `--btn-bar-*`). [새 창] 은 종단도가 그릴 때마다 맨 앞에 옮겨 붙인다(282-2).
|
||||
*
|
||||
* 선택 흐름:
|
||||
* [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체.
|
||||
* [직선화] → 측점 2개를 그래프에서 고름(클릭 = 늘 측점 · 271-1) → 두 측점의 라운드에 탄젠트한 직선으로 대체.
|
||||
* 고른 범위가 이미 직선이면 그 범위를 부분 구간으로 고름 → [▲][▼]로 꺾으면 가운데에
|
||||
* 라운드 · 양 끝에 기본 L 곡선. Esc = 시작(또는 부분 구간)만 취소 · 모드 유지.
|
||||
* [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림.
|
||||
* 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에
|
||||
* 라운드가 생기고 양쪽이 탄젠트 직선이 된다.
|
||||
* [자유지정] → 범위를 고르고 클릭으로 계획선을 잇는다(`design_profile_free` 몫 · 215-1).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -55,8 +55,12 @@ export interface ProfileTools {
|
||||
handleStationPick: (chainageM: number) => boolean;
|
||||
/** 그래프에서 직선을 눌렀을 때(구간 판정 결과) — 도구가 삼켰으면 true. */
|
||||
handleRunPick: (run: StraightRun | null) => boolean;
|
||||
/** 선택 중인 직선 구간들(강조 표시용). */
|
||||
/** [쉬프트]로 고른 직선 구간들(붉은 강조용) — [직선화] 부분 구간은 `subRun`(보라 띠 · 271-1). */
|
||||
selectedRuns: () => StraightRun[];
|
||||
/** [직선화] 중 고른 이미 직선인 부분 구간(▲▼ 꺾기 대상). */
|
||||
subRun: () => StraightRun | null;
|
||||
/** [직선화] 시작 측점 · 부분 구간만 취소(Esc · 모드 유지) — 취소한 것이 있으면 true. */
|
||||
cancelPick: () => boolean;
|
||||
/** 직선화 대기 중 첫 측점(강조 표시용). */
|
||||
pendingStation: () => number | null;
|
||||
/** 고른 직선을 ▲▼ 버튼과 같은 경로로 옮긴다(방향키가 같이 쓴다) — 대상이 없으면 false. */
|
||||
@@ -117,7 +121,15 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
return {
|
||||
mode: () => mode,
|
||||
nudge: step,
|
||||
selectedRuns: () => runs,
|
||||
selectedRuns: () => (mode === "shift" ? runs : []),
|
||||
subRun: () => (mode === "straighten" ? (runs[0] ?? null) : null),
|
||||
cancelPick() {
|
||||
if (mode !== "straighten" || (pending === null && !runs.length)) return false;
|
||||
pending = null;
|
||||
runs = [];
|
||||
callbacks.onChanged();
|
||||
return true;
|
||||
},
|
||||
pendingStation: () => pending,
|
||||
mountOverlay: callbacks.mountOverlay,
|
||||
clear() {
|
||||
@@ -129,6 +141,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
if (mode !== "straighten") return false;
|
||||
if (pending === null) {
|
||||
pending = chainageM;
|
||||
runs = [];
|
||||
callbacks.onChanged();
|
||||
return true;
|
||||
}
|
||||
@@ -222,7 +235,12 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
} else if (mode === "straighten" && pending !== null) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`;
|
||||
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (Esc 시작 취소)`;
|
||||
wrap.append(hint);
|
||||
} else if (mode === "straighten" && runs.length === 1) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = "이미 직선 — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선";
|
||||
wrap.append(hint);
|
||||
} else if (mode === "shift" && !runs.length) {
|
||||
const hint = document.createElement("em");
|
||||
|
||||
@@ -358,6 +358,15 @@
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
/* 범위 「시작–끝 · 길이」 글 — [직선화] · [자유지정] 실시간 띠 위(271-2 · 3). */
|
||||
.b05-profile-freemark__span {
|
||||
fill: var(--color-primary);
|
||||
font-size: var(--text-caption);
|
||||
paint-order: stroke;
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
/* 지나온 선 · 미리 보기 · 점 = 녹색(367-4) — 범위 띠 · 경계는 보라 그대로. */
|
||||
.b05-profile-freemark__line {
|
||||
stroke: var(--color-success);
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* `resources/tester/test_346_2_pipeline_cross_page.py`.
|
||||
* crossWithUphill cross 단계 입력에 단면 유형 기본값(상단측)을 얹음 — 서버 노선 저장이 단면 유형 ·
|
||||
* 측구 방향을 새 지형으로 정하는 것과 같은 규칙(넘기는 칸 = 부르는 쪽 입력).
|
||||
* draftSectionDetail ground · profile · cross → 상세(측점 · 지반 · 횡단 설계 · 얹힌 시설 제원).
|
||||
* crossWithShapes 얹힌 시설 맥락(포장 · 기울임 · 하강) = 지금 현황을 새 측점에 다시 얹은 그림에서(457).
|
||||
* draftSectionDetail ground · profile · cross → 상세(측점 · 지반 · 횡단 설계 · 그림 자료 · 카드 이름 = 지금
|
||||
* 현황을 새 측점에 다시 얹음 · 캐시 `withDraftShapes` 와 같은 `attachShapes` · 457).
|
||||
*
|
||||
* 순수 — 화면 · fetch · 세션 없음.
|
||||
* ========================================================================== */
|
||||
@@ -26,24 +28,26 @@ import type {
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
||||
import { stationLabel } from "../../common_util/common_util_station";
|
||||
import { attachShapes, type AttachType } from "../../common_util/common_util_structure_attach";
|
||||
import { registryDefaults } from "../../common_util/common_util_culvert_sets";
|
||||
import {
|
||||
planStations,
|
||||
SAME_STATION_M,
|
||||
type PlanPipe,
|
||||
type PlanStructure,
|
||||
type PlanType,
|
||||
} from "../../common_util/common_util_structure_station";
|
||||
import { stationIdOf } from "./B05_Profile_Pipeline_Grade";
|
||||
|
||||
/** 측점에 얹힌 시설 제원(저장 상세에서 측점 열쇠로 옮김) — 낡음 판정 · 그림이 읽음 · 카드 번호(`station_id` ·
|
||||
* 있으면 저장 카드 번호를 이어 씀 · 440). */
|
||||
export type SectionSpecs = Partial<
|
||||
Pick<
|
||||
CrossSection,
|
||||
| "station_id"
|
||||
| "structure"
|
||||
| "culvert"
|
||||
| "ford"
|
||||
| "box"
|
||||
| "ford_pavement"
|
||||
| "revetment"
|
||||
| "pavement"
|
||||
| "underdrain"
|
||||
>
|
||||
>;
|
||||
/** 측점 열쇠로 이어 받는 카드 번호(`station_id` · 저장 카드 번호를 이어 씀 · 440) — 그림 자료 · 이름은 안 옮김(457). */
|
||||
export type SectionSpecs = Partial<Pick<CrossSection, "station_id">>;
|
||||
|
||||
/** 지금 현황(follow 뒤 관 · 구조물) + 구조물 표 — 새 측점에 그림 · 이름 · 시설 맥락을 다시 얹는 재료(457). */
|
||||
export interface ShapeStatus {
|
||||
pipes: ReadonlyArray<PlanPipe>;
|
||||
structures: ReadonlyArray<PlanStructure>;
|
||||
types: ReadonlyArray<AttachType>;
|
||||
}
|
||||
|
||||
/** cross 단계 입력 꼴(`B06_Section_Cross_Stage` `CrossStageInput`) — 칸은 그 단계 몫. */
|
||||
export interface CrossInput {
|
||||
@@ -128,19 +132,6 @@ const kindOf = (station: PipelineStation): SectionStation["kind"] =>
|
||||
? "curve"
|
||||
: "irregular";
|
||||
|
||||
/** 제원의 누가거리 = 새 측점(서버 부착은 따라간 관 · 구조물 자리 = 그 측점) — 옛 값이면 구조물 면적이 관 자리를 잘못 잡음(349-0n). */
|
||||
function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs {
|
||||
const out: Record<string, unknown> = { ...own };
|
||||
for (const [name, spec] of Object.entries(own))
|
||||
if (
|
||||
spec &&
|
||||
typeof spec === "object" &&
|
||||
typeof (spec as { chainage_m?: unknown }).chainage_m === "number"
|
||||
)
|
||||
out[name] = { ...spec, chainage_m: chainageM };
|
||||
return out as SectionSpecs;
|
||||
}
|
||||
|
||||
/** 저장 상세 → 묶음 cross 출력(365-4 · 저장 노선 = 다시 셈 창구 한 벌이 고친 설계 그대로) — 측점이 상세와
|
||||
* 다르면(갈아끼움 전) null. */
|
||||
export function detailCross(
|
||||
@@ -181,7 +172,6 @@ export function stationSection(
|
||||
chainage_m: station.chainageM,
|
||||
label: stationLabel(station.chainageM, intervalM),
|
||||
kind: kindOf(station),
|
||||
...(own.structure ? { structure: own.structure } : {}),
|
||||
center_z: center,
|
||||
azimuth_deg: station.azimuthDeg,
|
||||
plan_radius_m: station.planRadiusM,
|
||||
@@ -215,8 +205,82 @@ export function stationSection(
|
||||
};
|
||||
}
|
||||
|
||||
/** 측점마다 그림 자료 · 카드 이름 = 지금 현황을 그 자리에 다시 얹음(캐시 `withDraftShapes` · 이름 = 측점 계획
|
||||
* 제목 · 457) — 옛 측점 그림을 옮기지 않음. */
|
||||
function attachStatus(sections: CrossSection[], status: ShapeStatus): void {
|
||||
const { pipes, structures, types } = status;
|
||||
attachShapes(sections as unknown as Array<Record<string, unknown>>, pipes, structures, types);
|
||||
const planned = planStations(pipes, structures, types, registryDefaults(types));
|
||||
for (const section of sections) {
|
||||
const hit = planned.find((p) => Math.abs(p.chainage_m - section.chainage_m) < SAME_STATION_M);
|
||||
if (hit) section.structure = hit.title;
|
||||
else delete section.structure;
|
||||
}
|
||||
}
|
||||
|
||||
/** 현황 묶음(입력 칸은 묶음 모양 `unknown` · 457) — 정의 옵션 · 이름이 빈 종류는 빈 목록 · 종류 id. */
|
||||
export const shapeStatus = (
|
||||
pipes: unknown,
|
||||
structures: unknown,
|
||||
types: ReadonlyArray<PlanType & { name?: string }>,
|
||||
): ShapeStatus =>
|
||||
({
|
||||
pipes,
|
||||
structures,
|
||||
types: types.map((type) => ({
|
||||
...type,
|
||||
name: type.name ?? type.type_id,
|
||||
options: type.options ?? [],
|
||||
})),
|
||||
}) as ShapeStatus;
|
||||
|
||||
/** 그 측점 그림 자료 → 얹힌 시설 맥락(B06 `Cross_Refresh` 와 같은 규칙 · 서버 `tilt_ranges` · `ford_drop_at` 짝). */
|
||||
function shapeContext(section: CrossSection): Record<string, unknown> {
|
||||
const drop = Number(section.ford?.overflow_depth_m);
|
||||
return {
|
||||
paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement),
|
||||
tilted: Boolean(section.ford_pavement || section.underdrain),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
|
||||
surfaceDropM: drop > 0 ? drop : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 측점마다 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강) = 지금 현황을 새 측점에 다시 얹은 그림에서(457). */
|
||||
export function crossWithShapes(
|
||||
input: CrossInput | null,
|
||||
stations: ReadonlyArray<PipelineStation>,
|
||||
status: ShapeStatus | null,
|
||||
): CrossInput | null {
|
||||
if (!input || !status) return input;
|
||||
const sections = stations.map((station) => ({ chainage_m: station.chainageM }) as CrossSection);
|
||||
attachShapes(
|
||||
sections as unknown as Array<Record<string, unknown>>,
|
||||
status.pipes,
|
||||
status.structures,
|
||||
status.types,
|
||||
);
|
||||
const byKey: CrossInput["byKey"] = { ...input.byKey };
|
||||
stations.forEach((station, i) => {
|
||||
byKey[station.key] = { ...byKey[station.key], ...shapeContext(sections[i]) };
|
||||
});
|
||||
return { ...input, byKey };
|
||||
}
|
||||
|
||||
/** cross 단계 입력 = 시설 맥락(새 자리 그림) · 단면 유형 기본값(상단측) · 소단(지금 구조물) 얹음 — 페이지 묶음 cross. */
|
||||
export const crossWithContext = (
|
||||
input: CrossInput | null,
|
||||
stations: ReadonlyArray<PipelineStation>,
|
||||
status: ShapeStatus | null,
|
||||
bermAt: BermAt | null,
|
||||
): CrossInput | null =>
|
||||
crossWithBerm(
|
||||
crossWithUphill(crossWithShapes(input, stations, status), stations),
|
||||
stations,
|
||||
bermAt,
|
||||
);
|
||||
|
||||
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름)는 그대로 · 측점 · 지반 · 횡단 · 계획선 표본은 새 노선
|
||||
* (십자선 대체 높이도 초안 종단 · 413). */
|
||||
* (십자선 대체 높이도 초안 종단 · 413) · 그림 자료 · 카드 이름 = `status`(지금 현황)를 새 측점에 다시 얹음(457). */
|
||||
export function draftSectionDetail(
|
||||
saved: SectionDetailResponse,
|
||||
ground: GroundOutput,
|
||||
@@ -224,15 +288,12 @@ export function draftSectionDetail(
|
||||
cross: CrossOutput | null,
|
||||
specs: ReadonlyMap<string, SectionSpecs>,
|
||||
intervalM: number,
|
||||
status: ShapeStatus | null = null,
|
||||
): SectionDetailResponse {
|
||||
const sections: CrossSection[] = ground.stations.map((station) =>
|
||||
stationSection(
|
||||
station,
|
||||
cross,
|
||||
atChainage(specs.get(station.key) ?? {}, station.chainageM),
|
||||
intervalM,
|
||||
),
|
||||
stationSection(station, cross, specs.get(station.key) ?? {}, intervalM),
|
||||
);
|
||||
if (status) attachStatus(sections, status);
|
||||
const { chainageM, z } = ground.longitudinal;
|
||||
return {
|
||||
longitudinal: {
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
* `A00_Common/design/logic/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터):
|
||||
*
|
||||
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
|
||||
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400).
|
||||
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400) ·
|
||||
* 추가 측점 좌표 따라감(459 · `followPlusStations`).
|
||||
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
|
||||
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`).
|
||||
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 ·
|
||||
* 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts`
|
||||
* `route_moved_stations` · `seat_route_moved` · 거울 시험 test_459_route_seat_mirror.py).
|
||||
* profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수.
|
||||
*
|
||||
* 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음.
|
||||
@@ -15,24 +18,34 @@
|
||||
import type {
|
||||
FollowPatch,
|
||||
PipelineInputs,
|
||||
PipelineStation,
|
||||
RangePart,
|
||||
RouteLine,
|
||||
StageFn,
|
||||
Vertex,
|
||||
} from "../../A00_Common/design/logic/design_pipeline_types";
|
||||
import {
|
||||
followPlusStations,
|
||||
followStructures,
|
||||
projectPipePoints,
|
||||
type FollowingPoint,
|
||||
type FollowingStructure,
|
||||
} from "../../common_util/common_util_route_follow";
|
||||
import { PLUS_OWNER_PREFIX, SAME_STATION_M } from "../../common_util/common_util_structure_station";
|
||||
import {
|
||||
buildAlignment,
|
||||
emptyEdits,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { carryBase, carryEdits, changedSpan, type XY } from "./B05_Profile_Grade_Carry";
|
||||
import {
|
||||
carryBase,
|
||||
carryEdits,
|
||||
changedSpan,
|
||||
lineMatcher,
|
||||
type XY,
|
||||
} from "./B05_Profile_Grade_Carry";
|
||||
import { seatBornNodes } from "./B05_Profile_Grade_Seat";
|
||||
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
|
||||
import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground";
|
||||
import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw";
|
||||
@@ -99,14 +112,17 @@ export function createFollowStage(deps: {
|
||||
}): StageFn<"follow"> {
|
||||
let ref: GradeRef | null = null;
|
||||
let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로
|
||||
let moved = new Set<string>(); // 노선 초안 동안 사용자가 옮긴 · 놓은 시설 id(459 · 427 세움)
|
||||
return (ctx) => {
|
||||
if (!ctx.inputs.route) moved = new Set();
|
||||
if (!ctx.inputs.route && deps.saved) return deps.saved();
|
||||
const before = ctx.before.route?.line ?? deps.savedLine();
|
||||
const now = ctx.out.route;
|
||||
if (!before || !now) return {};
|
||||
const line = xy(now.line);
|
||||
const patch: FollowPatch = {};
|
||||
const { pipes, structures, alignment } = ctx.inputs;
|
||||
const { pipes, structures, alignment, plusStations } = ctx.inputs;
|
||||
if (ctx.before.follow) userMoved(ctx.inputs, ctx.before.follow).forEach((id) => moved.add(id));
|
||||
if (Array.isArray(pipes) && pipes.length)
|
||||
patch.pipes = projectPipePoints(pipes as FollowingPoint[], line);
|
||||
if (Array.isArray(structures) && structures.length)
|
||||
@@ -116,6 +132,10 @@ export function createFollowStage(deps: {
|
||||
undefined,
|
||||
SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3)
|
||||
).map((moved) => moved.item);
|
||||
// 추가 측점도 땅 위 자리 따라감(459 · 서버 `follow_plus_stations` 짝) — 가게는 부르는 쪽이 씀
|
||||
if (plusStations?.length)
|
||||
patch.plusStations = followPlusStations(plusStations, xy(before), line);
|
||||
movedIds.set(patch, new Set(moved));
|
||||
const planline = ctx.before.planline;
|
||||
if (alignment !== carried && JSON.stringify(alignment) !== JSON.stringify(carried))
|
||||
ref =
|
||||
@@ -134,6 +154,71 @@ export function createFollowStage(deps: {
|
||||
};
|
||||
}
|
||||
|
||||
/** 같은 꼭짓점 자리(m) — `seatBornNodes` · 파이썬 `_SAME_STATION_M` 과 같은 0.006. */
|
||||
const SEAT_SAME_M = 0.006;
|
||||
|
||||
/** follow 출력마다 그때까지 사용자가 옮긴 시설 id — planline 이 427 세움에 씀(459). */
|
||||
const movedIds = new WeakMap<object, ReadonlySet<string>>();
|
||||
|
||||
type Facility = { id?: string | null; structure_id?: string | null; chainage_m?: number | null };
|
||||
|
||||
/** 입력 관 · 구조물 중 앞 follow 출력과 자리가 다른(사용자가 옮김) · 새로 놓은 것의 id. */
|
||||
function userMoved(
|
||||
inputs: Pick<PipelineInputs, "pipes" | "structures">,
|
||||
followed: FollowPatch,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of ["pipes", "structures"] as const) {
|
||||
const now = inputs[key];
|
||||
const was = followed[key];
|
||||
if (!Array.isArray(now) || !Array.isArray(was)) continue;
|
||||
const idOf = (item: Facility) => (key === "pipes" ? item.id : item.structure_id) ?? null;
|
||||
const at = new Map((was as Facility[]).map((item) => [idOf(item), item.chainage_m ?? null]));
|
||||
for (const item of now as Facility[]) {
|
||||
const id = idOf(item);
|
||||
const old = at.get(id);
|
||||
if (id && (old == null || Math.abs((item.chainage_m ?? 0) - old) > SAME_STATION_M))
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 노선 바뀜으로 옮긴 시설 측점(459) — 관 · 구조물 측점 중 옛 선과 같은 자리가 아닌 것(`lineMatcher` 짝
|
||||
* 없음) · 사용자가 옮긴 시설(`moved` id). 추가 측점(`plus-`)은 뺌. 파이썬 `route_moved_stations` 짝(그쪽은
|
||||
* 사용자가 옮긴 것을 관 · 구조물 저장이 세움). */
|
||||
export function routeMovedStations(
|
||||
stations: ReadonlyArray<Pick<PipelineStation, "chainageM" | "kind" | "id">>,
|
||||
oldXy: ReadonlyArray<XY>,
|
||||
newXy: ReadonlyArray<XY>,
|
||||
moved: ReadonlySet<string> = new Set(),
|
||||
): number[] {
|
||||
const match = lineMatcher(oldXy, newXy);
|
||||
return stations
|
||||
.filter(
|
||||
(station) =>
|
||||
(station.kind === "pipe" || station.kind === "structure") &&
|
||||
!station.id?.startsWith(PLUS_OWNER_PREFIX) &&
|
||||
((station.id !== null && moved.has(station.id)) || match(station.chainageM) === null),
|
||||
)
|
||||
.map((station) => station.chainageM)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 옮긴 시설 측점(`born`)에 427 꼭짓점을 다시 세움 — 그 자리 꼭짓점을 걷고 `seatBornNodes`(원지반 지남 ·
|
||||
* 이웃 1 mm 맞춤 · 균형 종단이면 옮기기 전 계획선 높이). 파이썬 `seat_route_moved` 짝. */
|
||||
export function seatRouteMoved(
|
||||
base: Pick<AlignmentBase, "chainage" | "ground" | "policy" | "basePvi">,
|
||||
born: ReadonlyArray<number>,
|
||||
held: ReadonlyArray<number> = [],
|
||||
): AlignmentBase["basePvi"] {
|
||||
if (!born.length) return base.basePvi;
|
||||
const kept = base.basePvi.filter((node) =>
|
||||
born.every((value) => Math.abs(node.chainage_m - value) >= SEAT_SAME_M),
|
||||
);
|
||||
return seatBornNodes(base, kept, born, held);
|
||||
}
|
||||
|
||||
/** 일반측점 격자 + 끝 — `stationsAt` 없을 때(시험 · 단독 묶음). */
|
||||
const gridOf = (route: RouteLine): number[] => {
|
||||
const grid: number[] = [];
|
||||
@@ -176,10 +261,30 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
|
||||
const savedLine = deps.savedLine();
|
||||
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
|
||||
const savedPvi = deps.storedPvi?.() ?? saved.basePvi;
|
||||
const carried = span
|
||||
? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi)
|
||||
: savedPvi;
|
||||
// 옮긴 시설 측점에 427 꼭짓점 다시 세움(459 · 서버 `_carry_profile` 짝 · 사용자가 옮긴 것은 즉시)
|
||||
const followed = ctx.out.follow;
|
||||
const moved = new Set(followed ? movedIds.get(followed) : []);
|
||||
if (followed) userMoved(ctx.inputs, followed).forEach((id) => moved.add(id));
|
||||
const born = savedLine
|
||||
? routeMovedStations(ground.stations, xy(savedLine), xy(route.line), moved)
|
||||
: [];
|
||||
const held = Object.keys(ctx.inputs.alignment?.station_offsets ?? {}).map(Number);
|
||||
return {
|
||||
policy: saved.policy, // 옛 기본 L 이어받음(서버 `carry_policy` 짝)
|
||||
onlyExplicitCurves: saved.onlyExplicitCurves,
|
||||
basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi,
|
||||
basePvi: seatRouteMoved(
|
||||
{
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
policy: saved.policy,
|
||||
basePvi: carried,
|
||||
},
|
||||
born,
|
||||
held,
|
||||
),
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
stations: ground.stations.map((station) => ({
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { AlignmentCurve, AlignmentPolicy } from "./B05_Profile_UI_Profile_A
|
||||
export const DEFAULT_CURVE_LENGTH_M = 20;
|
||||
/** 초기값 직선 문턱 R(m) — config `initial_straight_radius_m` 짝. */
|
||||
export const INITIAL_STRAIGHT_RADIUS_M = 500;
|
||||
/** 법정 미달 판정 오차(m) — R = L/Δ 를 반올림 높이로 되셈한 L 19.99998 거짓 미달 막음(271-1) · 파이썬 짝. */
|
||||
export const SHORTFALL_TOLERANCE_M = 1e-3;
|
||||
|
||||
/** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것 — 파이썬 `flat_bends` · 시종점 false. */
|
||||
export function flatBends(
|
||||
@@ -72,7 +74,10 @@ export function verticalCurveShortfalls(
|
||||
if (minR <= 0 && minL <= 0) return [];
|
||||
return curves
|
||||
.filter((curve) => !curve.omitted && !curve.skip_allowed)
|
||||
.filter((curve) => curve.r_m < minR - 1e-6 || curve.l_m < minL - 1e-6)
|
||||
.filter(
|
||||
(curve) =>
|
||||
curve.r_m < minR - SHORTFALL_TOLERANCE_M || curve.l_m < minL - SHORTFALL_TOLERANCE_M,
|
||||
)
|
||||
.map(
|
||||
(curve) =>
|
||||
`${curve.chainage_m.toFixed(1)}m: 종단곡선 R ${curve.r_m.toFixed(0)}m · L ${curve.l_m.toFixed(1)}m — ` +
|
||||
|
||||
@@ -235,6 +235,8 @@ export interface StationsOptions {
|
||||
types: ReadonlyArray<PlanType>;
|
||||
/** 종류 → 옵션 기본값(카드 이름) — `planStations` 그대로. */
|
||||
registry?: Readonly<Record<string, Record<string, unknown>>>;
|
||||
/** 추가 측점(정수 m) — 없으면 `planStations` 기본(전역 `plusStations()`). */
|
||||
plus?: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
/** 원래 격자 번호 — 스냅으로 옮긴 측점은 사라진 원래 격자 자리 중 가장 가까운 번호를 이어받음. */
|
||||
@@ -299,7 +301,13 @@ export function buildStations(
|
||||
// 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`).
|
||||
const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`));
|
||||
const planted = new Map<number, { kind: "pipe" | "structure"; id: string; role: string }>();
|
||||
for (const station of planStations(pipes, structures, options.types, options.registry)) {
|
||||
for (const station of planStations(
|
||||
pipes,
|
||||
structures,
|
||||
options.types,
|
||||
options.registry,
|
||||
options.plus,
|
||||
)) {
|
||||
if (station.chainage_m < 0 || station.chainage_m > total) continue;
|
||||
const near = grid.find((value) => Math.abs(value - station.chainage_m) < SNAP_M);
|
||||
const at = near ?? station.chainage_m;
|
||||
|
||||
@@ -109,6 +109,24 @@ export function hasStraightRuns(alignment: ProfileAlignment): boolean {
|
||||
return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index));
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 범위가 이미 한 직선이면 그 범위를 부분 구간으로 돌려준다(271-1) — [직선화] ▲▼ 로 꺾으면
|
||||
* 양 끝에 기본 L 곡선. 사이 꼭짓점이 없거나 하나라도 꺾였으면 null(보통 직선화).
|
||||
*/
|
||||
export function straightSubRun(
|
||||
alignment: ProfileAlignment,
|
||||
fromChainageM: number,
|
||||
toChainageM: number,
|
||||
): StraightRun | null {
|
||||
const fromM = Math.min(fromChainageM, toChainageM);
|
||||
const toM = Math.max(fromChainageM, toChainageM);
|
||||
const inside = alignment.pvi
|
||||
.map((node, index) => ({ at: node.chainage_m, index }))
|
||||
.filter(({ at }) => at > fromM + SAME_STATION_M && at < toM - SAME_STATION_M);
|
||||
if (!inside.length || !inside.every(({ index }) => straightAt(alignment.pvi, index))) return null;
|
||||
return { fromM, toM, nodes: [fromM, ...inside.map(({ at }) => at), toM] };
|
||||
}
|
||||
|
||||
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
|
||||
function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] {
|
||||
return pviChainages(alignment).filter(
|
||||
@@ -131,17 +149,33 @@ function withOffsets(
|
||||
return { station_offsets: stationOffsets, curve_radii: curveRadii };
|
||||
}
|
||||
|
||||
/**
|
||||
* 범위 끝 R 이 저장 자동 R 과 같으면 지운다 — 끝 곡선 = 기본 L(271-1 · ▲▼ 433-13 과 같은 규칙) ·
|
||||
* 사용자 R 은 둠. `radii` 를 고쳐 쓴다.
|
||||
*/
|
||||
export function dropAutoEndRadii(
|
||||
radii: Record<string, number>,
|
||||
ends: number[],
|
||||
autoRadii: Readonly<Record<string, number>>,
|
||||
): void {
|
||||
ends.map(chainageKey).forEach((key) => {
|
||||
if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다.
|
||||
*
|
||||
* 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의
|
||||
* 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다).
|
||||
* 끝 R 이 저장 자동 R(`autoRadii`)이면 지워 기본 L(271-1).
|
||||
*/
|
||||
export function straightenBetween(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
fromChainageM: number,
|
||||
toChainageM: number,
|
||||
autoRadii: Readonly<Record<string, number>> = {},
|
||||
): AlignmentEdits {
|
||||
const fromM = Math.min(fromChainageM, toChainageM);
|
||||
const toM = Math.max(fromChainageM, toChainageM);
|
||||
@@ -156,11 +190,13 @@ export function straightenBetween(
|
||||
const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span;
|
||||
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
|
||||
});
|
||||
return withOffsets(
|
||||
const next = withOffsets(
|
||||
edits,
|
||||
[...updates, ...pinEnds(base, current, [fromM, toM])],
|
||||
inner.map(chainageKey),
|
||||
);
|
||||
dropAutoEndRadii(next.curve_radii, [fromM, toM], autoRadii);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,6 +281,8 @@ export function tiltStraightRun(
|
||||
if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ);
|
||||
else place(chainage, pivot, pivotZ, run.toM, endZ);
|
||||
});
|
||||
// 부분 구간(271-1) 끝이 꼭짓점 아닌 측점이면 지금 높이로 세움 — 끝이 따라 움직이지 않게
|
||||
updates.push(...pinEnds(base, current, [run.fromM, run.toM]));
|
||||
|
||||
const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12);
|
||||
const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12);
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py.
|
||||
* - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기
|
||||
* 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u).
|
||||
* - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝).
|
||||
* - `followPlusStations` ↔ 파이썬 `B05_Profile_Plus_Stations.follow_plus_stations`(459 · 노선 초안 동안).
|
||||
* 거울 시험 = resources/tester/test_344_2_route_pairs.py.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -142,6 +144,66 @@ export function rebasePipeDraft<P extends FollowingPoint & { id?: string | null
|
||||
return [...kept, ...added].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
}
|
||||
|
||||
type StructureLike = FollowingStructure & { structure_id?: string | null };
|
||||
|
||||
/** 자리 셋(기준 · 구간 끝)이 같은가 — `shift` 만큼 민 자리로 견줌(구간 길이 = 시설 치수). */
|
||||
const samePositions = (a: FollowingStructure, b: FollowingStructure, shift = 0): boolean =>
|
||||
(["chainage_m", "start_m", "end_m"] as const).every(
|
||||
(key) =>
|
||||
(a[key] == null) === (b[key] == null) &&
|
||||
Math.abs((a[key] ?? 0) + shift - (b[key] ?? 0)) <= SAME_STATION_M,
|
||||
);
|
||||
|
||||
/** 노선을 저장한 [저장] — 세션 구조물 초안을 서버가 따라 옮긴 정본(`after`) 위에 얹은 목록(459 · 관
|
||||
* `rebasePipeDraft` 짝). 따라가기만 한 것(자리 = 노선 저장 전 정본 `before` 그대로 · 또는 땅 위 자리가 같고
|
||||
* 기준 누가거리가 서버 따라감과 같음)은 서버 자리(기준 · 구간 끝 · 좌표) · 사용자가 옮긴 것(마커 · 폼 =
|
||||
* 누가거리만 옮겨 좌표 낡음 포함) · 새로 놓은 것은 초안 그대로 · 옵션 · 메모는 늘 초안 · 짝은 `structure_id`. */
|
||||
export function rebaseStructureDraft<S extends StructureLike>(
|
||||
draft: ReadonlyArray<S>,
|
||||
before: ReadonlyArray<StructureLike>,
|
||||
after: ReadonlyArray<StructureLike>,
|
||||
): S[] {
|
||||
const old = new Map(before.map((item) => [item.structure_id, item]));
|
||||
const moved = new Map(after.map((item) => [item.structure_id, item]));
|
||||
return draft.map((item) => {
|
||||
const was = item.structure_id ? old.get(item.structure_id) : undefined;
|
||||
const now = item.structure_id ? moved.get(item.structure_id) : undefined;
|
||||
if (!was || !now) return item;
|
||||
const followed =
|
||||
item.x != null &&
|
||||
item.y != null &&
|
||||
was.x != null &&
|
||||
was.y != null &&
|
||||
Math.hypot(item.x - was.x, item.y - was.y) <= FOLLOW_SAME_XY_M &&
|
||||
samePositions(was, item, (item.chainage_m ?? 0) - (was.chainage_m ?? 0)) &&
|
||||
Math.abs((item.chainage_m ?? 0) - (now.chainage_m ?? 0)) <= SAME_STATION_M;
|
||||
if (!followed && !samePositions(item, was)) return item;
|
||||
return {
|
||||
...item,
|
||||
chainage_m: now.chainage_m ?? item.chainage_m,
|
||||
start_m: now.start_m ?? null,
|
||||
end_m: now.end_m ?? null,
|
||||
x: now.x ?? item.x,
|
||||
y: now.y ?? item.y,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 추가 측점이 노선을 따라감(459) — 옛 선(`from`) 위 누가거리의 땅 위 자리를 새 선(`to`)에 투영한 정수 m
|
||||
* (같은 m 하나 · 오름차순). 파이썬 `follow_plus_stations` 짝(`follow_structures` 같은 셈). */
|
||||
export function followPlusStations(
|
||||
list: ReadonlyArray<number>,
|
||||
from: ReadonlyArray<XY>,
|
||||
to: ReadonlyArray<XY>,
|
||||
): number[] {
|
||||
const placed = placeOnLine(
|
||||
list.map((chainage_m) => ({ chainage_m })),
|
||||
from,
|
||||
);
|
||||
const moved = followStructures(placed, to).map(({ item }) => item.chainage_m!);
|
||||
return [...new Set(moved)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 따라가기만 한 관 = 정본과 같은 땅 위 자리(m) — 서버 좌표가 mm 3자리라 그 반올림 폭. */
|
||||
const FOLLOW_SAME_XY_M = 0.001;
|
||||
|
||||
|
||||
@@ -239,3 +239,12 @@
|
||||
## 요구 270 (10-18 · 사용자)
|
||||
|
||||
1. 다음으로 추가 오류가 발견됨. 평면의 구조물 측점이 괜찮을때도 있지만 평면 이동을 하다보면 연동측점에 연결이 된다거나 구조물 형상이 측점은 맞지만 구조물이 없는 상태가 되기도 함. 세밀하게 검증 필요. 내가 요청한건 노선 변경시 최대한 측점번호를 옮기더라도 구조물의 절대 위치를 유사한 곳으로 옮겼으면 했고 이때 옮기게되면 다시 그지점에 횡단도와 종단도에 관련 데이터들이 다시 생성되어야하거든 지금은 이동만되고 있다고 보여지네 면밀히 검토 후 반영해줘.
|
||||
|
||||
## 요구 271 (10-18 · 사용자)
|
||||
|
||||
종단 그래프 관련 신규 기능 추가 및 수정 확인
|
||||
1. 직선화 기능에서 종단 R이 없는 지점을 선택하는 경우 붉은 색 세로점선과 계획선에 중첩된 붉은선이 생김 안생기게 변경. 단, 사용자가 지정한 영역이 직선 영역이라면 시작과 종료 측점에 신규 R이 생성되어야하며 이때도 기본 L값은 지켜야함.
|
||||
2. 직선화 기능에서 시작 세로선을 선택하면 마우스커서에 따라 실시간으로 영역을 알려주는 기능 추가(자유지정의 지정영역 활성화 확인)
|
||||
3. 자유지정 기능에서 시작세로선 선택하면 마우스 커서에 따라 영역을 실시간으로 알려주는 기능 추가
|
||||
4. 너도 브라우져로 분석이 가능하니 화면 확인이 필요한 사항은 너가 확인
|
||||
5. B05와 B06의 같은 페이지 처럼 동작해야함. 원척적으로 구조를 그렇게 만들어 달라고 너에게 요청했음. 새로고침 뒤 B06에서 업데이트가 되면 B05에서도 업데이트가 되는게 맞음.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 요구 271-1~3 — 종단 직선화 붉은 강조 · 끝 R 기본 L · 영역 실시간 띠
|
||||
|
||||
분석 = 클로드 에이전트(Opus 5.5 · 읽기만 · 진짜 TS node 재현 · scratchpad `req271\repro.cjs`) · 브레인 판단.
|
||||
|
||||
## 1. 붉은 세로 점선 · 붉은 선 정체
|
||||
|
||||
- 경고 아님 — [쉬프트]용 「고른 직선」 강조(RunHighlight)가 직선화 모드에서 뜸.
|
||||
- 직선화 첫 클릭이 직선 구간 안쪽이면 측점 고름 대신 직선 전체를 기울이기 대상으로 고름(`B05_Profile_UI_Profile_Panel_Tools.ts:181-188` · 고름 `B05_Profile_UI_Profile_Tools.ts:155-160, 206` · 그림 `B05_Profile_UI_Profile_RunHighlight.ts:74-111` · 색 `B05_Profile_UI_Style_Table.css:326-341`).
|
||||
- 「R 없는 지점」 = 직선 위 꼭짓점(426 판정 · `tidyEdits` 가 R 지움) 또는 직선 안 빈 곳 · 438 초기 직선화로 긴 직선이 많아 자주 걸림.
|
||||
- 덤: 직선화 빈 곳 클릭이 측점에 안 붙음(`B05_Profile_UI_Profile_Render.ts:226-232` · 자유지정은 `snapStation`).
|
||||
|
||||
## 2. 곡선 규칙(지금)
|
||||
|
||||
- 편집 = `station_offsets`(높이) · `curve_radii`(R) · 열쇠 chainageKey · 측점마다 꼭짓점 · 끝은 `pinEnds`.
|
||||
- R 지정 꼭짓점 L = R·|Δ| · 지정 없는 꺾임 = 기본 L(TS `logic/B05_Profile_UI_Profile_Alignment.ts:297-358` ↔ `B05_Profile_Engine_Grade_Alignment.py:306-360`) · BP · EP 곡선 없음.
|
||||
- 직선화 뒤 끝 꼭짓점이 R 없으면 L 20 곡선 생김(정상) · 끝에 저장 자동 R 이 남으면 그 R 을 써서 L ≠ 20.
|
||||
- 이미 직선인 범위 = Δ 0 → 곡선 못 생김.
|
||||
- 덤 흠: tilt · 자유지정이 적는 R = L/Δ 를 반올림 높이로 다시 셈하면 L 19.99998 → 거짓 「L 20m 미달」(오차 1e-6 · `logic/B05_Profile_Profile_CurveLaw.ts:75` · `B05_Profile_Engine_Grade_CurveLaw.py:63`).
|
||||
|
||||
## 3. 정함(브레인)
|
||||
|
||||
- 직선화 모드 클릭 = 늘 측점 고름(181-188 갈림 지움 · 붉은 강조 안 생김) · 클릭은 `snapStation` 으로 측점에 붙임.
|
||||
- 끝 꼭짓점 R 이 저장 자동 R 과 같으면 지움(433-13 ▲▼ 규칙과 같음 · 사용자 R 은 둠) → 끝 곡선 기본 L 20 · 자유지정 `buildFreeEdits` 끝도 같은 규칙.
|
||||
- 이미 직선인 범위 = 그 범위를 부분 구간으로 고름(보라 띠 · 안내 「이미 직선 — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선」) · ▲▼ = `tiltStraightRun(sub)` → 양 끝 L 20 · BP · EP 는 곡선 없음.
|
||||
- 법정 판정 오차 = 1e-3 m(TS ↔ 파이썬 짝).
|
||||
- 실시간 띠 = 새 도우미 `A00_Common/design/design_profile_range_band.ts`(띠 + 세로선 + 「시작–끝 · 길이」 글) · 직선화 · 자유지정 range 단계 함께 · 첫 클릭 뒤 커서 측점까지 · 그래프 밖 = 시작선만 · Esc = 시작만 취소(모드 유지) · 반대 방향 정렬.
|
||||
- 서버 = 편집 꼴 같음 · `_apply_alignment_edits` → `build_alignment` 가 같은 규칙 → 거울 시험만.
|
||||
|
||||
## 4. 파일 · 줄 수(한도 700)
|
||||
|
||||
| 파일 | 줄 | 바뀜 |
|
||||
|---|---|---|
|
||||
| `B05_Profile_UI_Profile_Panel_Tools.ts` | 364 | 갈림 지움 · 붙음 · 띠 · Esc |
|
||||
| `B05_Profile_UI_Profile_Tools.ts` | 240 | 안내 · 부분 구간 고름 |
|
||||
| `logic/B05_Profile_UI_Profile_Straighten.ts` | 306 | 끝 자동 R 규칙 |
|
||||
| `A00_Common/design/logic/design_profile_free.ts` | 401 | hoverStation · marks |
|
||||
| `A00_Common/design/design_profile_free_view.ts` | 217 | 띠 그림 = 도우미 |
|
||||
| `A00_Common/design/design_profile_range_band.ts` | 새 | ~70 |
|
||||
| `logic/B05_Profile_Profile_CurveLaw.ts` · `B05_Profile_Engine_Grade_CurveLaw.py` | 81 · 68 | 오차 짝 |
|
||||
|
||||
손대지 않음: `logic/B05_Profile_UI_Profile_Alignment.ts`(700) · `design_profile.ts`(691) · Render · RunHighlight · 서버 묶음 영향 없음.
|
||||
|
||||
## 5. 화면 확인 목록(밝음 · 어두움)
|
||||
|
||||
1. 직선 안 R 없는 꼭짓점 첫 클릭 = 붉은 선 · 점선 없음 · 보라 시작선.
|
||||
2. 빈 곳 클릭 = 가까운 측점선에 붙음.
|
||||
3. 첫 클릭 뒤 마우스 이동 = 띠가 커서 측점까지 · 글 · 둘째 클릭 범위 = 띠.
|
||||
4. 왼쪽으로 고름 = 같은 결과.
|
||||
5. 끝이 R 없는 점 = L 20 곡선 · 법정 경고 칩 그대로.
|
||||
6. 끝에 저장 자동 R = 직선화 뒤 L 20 · 손 R 은 그대로.
|
||||
7. BP · EP 끝 = 곡선 없음.
|
||||
8. 이미 직선 범위 = 보라 띠 + 안내 · ▲▼ = 양 끝 L 20 · 거짓 미달 경고 없음.
|
||||
9. 곡선 섞인 범위 = 안쪽 R 지움 · 양 끝 L 20.
|
||||
10. Esc = 시작만 취소 · 고른 측점 안 풀림.
|
||||
11. 그래프 밖 = 시작선만 · 다시 들어오면 이어짐.
|
||||
12. 가로 줌 · 스크롤 중 띠 = 측점선 자리.
|
||||
13. ↶ 한 번에 원위치 · ↷.
|
||||
14. 자유지정 range 단계 실시간 띠 · 둘째 클릭 뒤 고정 · armed · drawing 미리보기 그대로 · Backspace · ↶ · Esc.
|
||||
15. [쉬프트] 붉은 강조는 그대로.
|
||||
16. 팝아웃 종단에서 3 · 14.
|
||||
17. 직선화 중 ▲ 단추가 계획고 조정 + 고름 둘로 새는지.
|
||||
@@ -0,0 +1,56 @@
|
||||
# 요구 271-5 — B05 · B06 한 페이지(새로고침 뒤 B06 고침 → B05 반영)
|
||||
|
||||
분석 = 클로드 에이전트(Opus 5.5 · 읽기만 · node 재현 `scratchpad\req271b\r271.cjs` · 진짜 Section_Store · design_pipe_ids) · 브레인 판단.
|
||||
|
||||
## 1. 새로고침해 B06 로 들어올 때
|
||||
|
||||
- 페이지(`design_page.ts:111`)는 관 id 장부 · 관 길이 출처만 세움 · 보기 둘은 처음 보일 때 세움(`:88, 254-263`).
|
||||
- B06 보기가 세우는 한 벌: 좌측 패널 · 구조물 폼 · 종단도 · 유토 · 표 · 배수유역도(`B06_Section_UI_Section_View_Profile.ts:277` → `design_shared.ts:183`) · 계산 묶음(`design_shared.ts:222`).
|
||||
- B05 를 열 때까지 미뤄지는 것: 평면 편집기 · 평면 마커 · 3D · B05 좌측(설정 · 추천 · 보기) · 구조물 다리 · 상단측.
|
||||
- B05 보기 안에만 있는 상태: `design_view_profile.ts:114` lastPipes · `:118` sessionIds · `:119-140` shownPipes(관 id 장부 place · prune 유일한 자리) · `B05_Profile_UI_Page_Structures.ts:66-155` 비정규 측점 · 관 · 구조물 · 알약 목록 · 페이지 설정을 종단도에 얹는 일(`design_view_profile_sections.ts:89-91` · `design_view_profile.ts:375-377`) · 상단측 덮어쓰기 · `profileSaveParts`.
|
||||
|
||||
## 2. 조작별 반영(P1 = B06 새로고침 → 조작 → B05 처음 엶 · P2 = B05 먼저 열어 숨김 → 조작 → B05 보임)
|
||||
|
||||
| B06 조작 | P1 | P2 |
|
||||
|---|---|---|
|
||||
| 카드 단추 · 쉬프트 · 소단 · 암선 · 조정창 · [전체 반영] | ✓ | ✓ |
|
||||
| 종단 ▲▼ · 직선화 · 자유지정 | ✓ | ✓ |
|
||||
| 구조물 폼 추가 · 옮김 · 지움 | ✓ | 평면 패널 마커 ✗(숨은 B05 다리 안 따라감) |
|
||||
| 관 추가 · 옮김 · 지움 | ✓(장부 헛 기록은 B05 열 때 걷힘) | 평면 패널 관 마커 ✗(보일 때만 · `:217`) |
|
||||
| 평면 패널 노선 끌기 | — | 패널 마커 ✗ · 보일 때 bridge.load carryEdits=true(`:470`) ≠ 보이던 때(`:164` false) → 계획고 편집 두 번 옮김 위험(추정) |
|
||||
| 반폭 넓힘 재생성 | ✓ | ✗ B05 가 옛 상세 객체 쥠(R1 재현) |
|
||||
| [측점 만들기] | ✗ 두 보기 다(R2 재현 · `B06_Section_UI_Missing_Stations.ts:91`) | ✗ |
|
||||
| [저장] | 요청 다름(원인 4) | ✓ |
|
||||
| 새로고침 자체 | 측점 라벨 오프셋 · 기울기 상한(448) · 흙덮기 강제가 B05 열기 전까지 다름 | ✓ |
|
||||
|
||||
- R3 재현: B06 만 연 채 351 관(pipe-s1) → 360 → 버림 → 장부 {s1@360 헛 · s2@351}(451 잔여).
|
||||
- 반대 방향: B05 폼 세월교 · BOX 옵션이 `shared.pipeOptionQueue`(메모리 · `design_shared.ts:141`)에만 → B06 안 연 채 새로고침이면 잃음(추정) · 페이지 설정 3종은 B05 를 거쳐야 종단도에 얹힘.
|
||||
|
||||
## 3. 원인(순위)
|
||||
|
||||
1. 시설 줄(구조물 + 관 → 알약 · 비정규 측점 · 평면 마커 · 관 id 장부)을 보기마다 따로 셈 — B05 = 구조물 다리 + shownPipes · B06 = `syncMarks`(`B06_Section_UI_Section_View_Profile.ts:183`) · B05 몫은 B05 가 설 때만 · 숨으면 가게 안 들음.
|
||||
2. 페이지 설정을 종단도에 얹는 일이 B05 좌측 패널에만.
|
||||
3. 상세 가게 갈아끼움 알림이 강제 다시 받기(넓힘)에 없음 · [측점 만들기] 뒤 캐시 안 비움.
|
||||
4. [저장] 상단측 차례 일부가 B05 보기 몫(`profileSaveParts`) — B05 안 열었으면 기본 창구(`design_save.ts:187`)로 · 경로 확정 요청(filter · method · 지표면 id)이 빠짐(`B05_Profile_Router_Lifecycle.py:75-131` · 영향 추정).
|
||||
5. 보기 사이 전달이 메모리에만 · 길마다 인자 다름(pipeOptionQueue · carryEdits).
|
||||
|
||||
## 4. 정함(브레인)
|
||||
|
||||
- 원칙: 두 보기가 같이 보는 파생값은 페이지가 한 번 세우고 가게를 늘 들음(순수 계산 · 숨은 동안도) · 보기는 보일 때 그리기만 · 처음 서든 다시 보이든 같은 함수로 「지금 페이지 값」을 읽음(서버 GET 아님).
|
||||
- (A) 페이지 한 벌 `A00_Common/design/design_facility_lane.ts` — 입력 = 가게 구조물(없으면 저장분) · 관 보고 + 관 초안 · 종류 표 · 추가 측점 → 출력 = 알약 · 비정규 측점 · 흙덮기 관 · 관 id 장부 맞춤(sessionIds 포함) · 종단도 · 평면 마커 원천에 여기서만 씀 · B05 다리 = 목록 · 종류 · 저장만 · B06 `syncMarks` = 읽기만 · carryEdits 차이 사라짐.
|
||||
- (B) 페이지 설정 한 칸 — 종단도가 설 때 세션 초안(448) 있으면 그것 · 없으면 저장 노선 설정 · 세션 칸도 들음 · B05 패널은 세션에 쓰기만.
|
||||
- (C) 강제 다시 받기 = 갈아끼움 알림 · [측점 만들기] 뒤 캐시 비우고 강제로 받음.
|
||||
- (D) [저장] 기본 창구 = B05 몫과 같은 본문(노선 캐시 지표 조건 + 확정 지표면 id · 비정규 목록 빈 목록) · B05 몫은 그 함수를 부름.
|
||||
- (E) pipeOptionQueue → 세션 칸.
|
||||
|
||||
## 5. 나눔
|
||||
|
||||
| 절 | 창 | 파일 | 시작 |
|
||||
|---|---|---|---|
|
||||
| 462 (A) · (E) | sub2 | `design_facility_lane.ts`(새) · `design_shared.ts` · `design_pipe_ids.ts` · `design_view_profile.ts` · `B05_Profile_UI_Page_Structures.ts` · `B06_Section_UI_Section_View_Profile.ts` · `B06_Section_UI_Page_Structures_Panel.ts` | 지금 |
|
||||
| 463 (B) · (C) · (D) | sub6 | `design_profile.ts` · `design_view_profile_sections.ts` · `B05_Profile_UI_Panel.ts` · `B06_Section_Section_Store.ts` · `B06_Section_UI_Missing_Stations.ts` · `design_save.ts` · `B05_Profile_UI_Page_Actions.ts` · `B05_Profile_Api_Fetch.ts` | 458 · 459 합친 뒤(Section_Store · design_save 겹침) |
|
||||
|
||||
## 6. 시험 · 화면 확인
|
||||
|
||||
- 시험: `test_271_facility_lane`(B06 먼저 → B05 읽음 = B05 먼저 듣는 중 · 알약 · 측점 · 평면 마커 · 장부 같음 · 헛 id 0) · `test_271_detail_notify`(강제 받기 · [측점 만들기] 뒤 듣는 쪽 한 번) · `test_271_save_same`(B05 있을 때 · 없을 때 요청 줄 같음) · `test_271_profile_settings`.
|
||||
- 화면(P1 · P2 · P3 = B06 새로고침 → 평면 패널 엶): 평면 마커 수 · 자리 · 발자국 · 평면 횡단 판 · 종단 알약 · 측점선 · 라벨 오프셋 · 기울기 빨강 · 유토 합계 · 좌측 목록 · 고름 강조 · 장부 헛 기록 0 · [3D 업데이트] — 카드 단추 넷 · 쉬프트 · 소단 · 암선 · 조정창 · 구조물 폼 · 종단 관 추가 · 끌기 · 지움 · 평면 패널 교점 끌기 · 되돌리기 · [측점 만들기] · 넓힘 · [전체 반영] · ▲▼ · 직선화 · 자유지정 · [저장] 두 길 요청 줄 · [확정] · [초기화] · 반대 방향 · 예외(빈 관 목록 · 같은 자리 관 둘 · 저장 안 하고 오가기 · 계산 중 · 저장 중 전환).
|
||||
@@ -0,0 +1,306 @@
|
||||
/* 271-1~3 종단 직선화 측점 고름 · 끝 R 기본 L · 실시간 띠 단위검증 헬퍼 — `_Straighten.ts` ·
|
||||
* `_Tools.ts` · `design_profile_free.ts` · `design_profile_range_band.ts` 를 그 자리에서 트랜스파일해
|
||||
* 돌리고 파이썬 거울 입력(base · edits · TS 곡선)을 JSON 으로 낸다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
// 화면 틀 파일(@ui 별칭)은 Node 가 못 읽음 — 도구줄 그림만 쓰므로 빈 그림으로 갈음
|
||||
const STUBS = { "./B05_Profile_UI_RouteEdit_Chrome": { HISTORY_ICON: { undo: "", redo: "" } } };
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const near = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, near);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
|
||||
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
const st = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts"));
|
||||
const law = loadTs(path.join(LOGIC, "B05_Profile_Profile_CurveLaw.ts"));
|
||||
const tools = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Tools.ts"));
|
||||
const DESIGN = path.join(ROOT, "A00_Common", "design");
|
||||
const free = loadTs(path.join(DESIGN, "logic", "design_profile_free.ts"));
|
||||
const band = loadTs(path.join(DESIGN, "design_profile_range_band.ts"));
|
||||
|
||||
const policy = {
|
||||
station_interval_m: 20,
|
||||
curve_radius_ratio: 1,
|
||||
curve_tangent_max_ratio: 0.5,
|
||||
curve_skip_legal_exception: false,
|
||||
default_curve_length_m: 20,
|
||||
default_curve_radius_m: 300,
|
||||
balance_tolerance_percent: 10,
|
||||
edit_step_m: 0.1,
|
||||
grade_violation_policy: "warn",
|
||||
max_grade_pct: 14,
|
||||
curve_skip_delta_pct: 0,
|
||||
paved: true,
|
||||
min_vertical_radius_m: 100,
|
||||
min_curve_length_m: 20,
|
||||
};
|
||||
// 0~300 m · 20 m 측점 — 마디마다 기울기가 다른 전체 측점 폴리라인(서버와 같이 지정 없는 꺾임 = 기본 L)
|
||||
const S = Array.from({ length: 16 }, (_, i) => i * 20);
|
||||
const g = (s) => 100 + 0.03 * s + 0.6 * Math.sin(s / 13);
|
||||
const base = {
|
||||
policy,
|
||||
onlyExplicitCurves: false,
|
||||
basePvi: S.map((s) => ({ chainage_m: s, elevation_m: +g(s).toFixed(6) })),
|
||||
chainage: S,
|
||||
ground: S.map((s) => +g(s).toFixed(6)),
|
||||
stations: S.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
|
||||
};
|
||||
const tidy = (e) => st.tidyEdits(base, e);
|
||||
const build = (e) => align.buildAlignment(base, e);
|
||||
const curves = (e) => build(e).curves.map((c) => ({ at: c.chainage_m, l: c.l_m, r: c.r_m }));
|
||||
const shortfalls = (e) => build(e).warnings.filter((w) => w.includes("미달"));
|
||||
|
||||
const out = {};
|
||||
const mirror = [];
|
||||
const keep = (name, edits) => {
|
||||
mirror.push({ name, edits, curves: curves(edits).map((c) => [c.at, c.r, c.l]) });
|
||||
return edits;
|
||||
};
|
||||
|
||||
// ① 직선화 40~200 — 사이 곡선 없음 · 양 끝 기본 L 20
|
||||
const e1 = keep("straight", tidy(st.straightenBetween(base, align.emptyEdits(), 40, 200)));
|
||||
const a1 = build(e1);
|
||||
out.straight = { curves: curves(e1), radii: Object.keys(e1.curve_radii) };
|
||||
|
||||
// ② 이미 직선인 범위 = 부분 구간 · 반대 방향 같음 · 꺾인 · 섞인 범위 = null
|
||||
out.sub = st.straightSubRun(a1, 80, 160);
|
||||
out.subReverse = st.straightSubRun(a1, 160, 80);
|
||||
out.subBent = st.straightSubRun(build(align.emptyEdits()), 40, 200);
|
||||
out.subMixed = st.straightSubRun(a1, 100, 260);
|
||||
out.subOneSpan = st.straightSubRun(a1, 80, 100);
|
||||
|
||||
// ③ 부분 구간 ▲(틸트) = 가운데 + 양 끝 L 20 · 거짓 미달 없음
|
||||
const e3 = keep("tiltSub", tidy(st.tiltStraightRun(base, e1, out.sub, 0.5)));
|
||||
out.tiltSub = { curves: curves(e3), shortfalls: shortfalls(e3) };
|
||||
// 판정 오차 짝(1e-3) — 옛 1e-6 이면 L 19.99998 이 미달로 읽힘
|
||||
out.tolerance = law.SHORTFALL_TOLERANCE_M;
|
||||
out.nearMiss = law.verticalCurveShortfalls(
|
||||
[{ chainage_m: 120, l_m: 19.99998, r_m: 800, omitted: false, skip_allowed: false }],
|
||||
policy,
|
||||
);
|
||||
out.realMiss = law.verticalCurveShortfalls(
|
||||
[{ chainage_m: 120, l_m: 19.9, r_m: 800, omitted: false, skip_allowed: false }],
|
||||
policy,
|
||||
);
|
||||
|
||||
// ④ 끝에 저장 자동 R → 지움(기본 L 20) · 사용자 R 은 둠
|
||||
const saved = { "40.000": 5330, "200.000": 1084 };
|
||||
const savedEdits = { station_offsets: {}, curve_radii: { ...saved } };
|
||||
const e4 = keep("autoEnds", tidy(st.straightenBetween(base, savedEdits, 40, 200, saved)));
|
||||
out.autoEnds = { radii: Object.keys(e4.curve_radii), curves: curves(e4) };
|
||||
const userEdits = { station_offsets: {}, curve_radii: { "40.000": 600, "200.000": 1084 } };
|
||||
const e4u = tidy(st.straightenBetween(base, userEdits, 40, 200, saved));
|
||||
out.userEnd = { radii: e4u.curve_radii };
|
||||
// 자동 R 기준 없이(옛 부름) = 그대로 남음
|
||||
out.noAuto = Object.keys(tidy(st.straightenBetween(base, savedEdits, 40, 200)).curve_radii);
|
||||
|
||||
// ⑤ BP · EP — 시종점 곡선 없음
|
||||
const e5 = keep("bpEp", tidy(st.straightenBetween(base, align.emptyEdits(), 0, 300)));
|
||||
out.bpEp = curves(e5);
|
||||
const eBP = tidy(st.straightenBetween(base, align.emptyEdits(), 0, 200));
|
||||
const subBP = st.straightSubRun(build(eBP), 0, 80);
|
||||
const e5t = keep("tiltBp", tidy(st.tiltStraightRun(base, eBP, subBP, 0.5)));
|
||||
out.tiltBp = curves(e5t).map((c) => c.at);
|
||||
|
||||
// ⑥ 곡선 섞인 범위 = 안쪽 R 지움 · 양 끝 L 20
|
||||
const mixedIn = { station_offsets: {}, curve_radii: { "120.000": 900, "140.000": 700 } };
|
||||
const e6 = keep("mixed", tidy(st.straightenBetween(base, mixedIn, 100, 260, {})));
|
||||
out.mixed = { radii: Object.keys(e6.curve_radii), curves: curves(e6) };
|
||||
|
||||
// ⑦ 자유지정 끝 자동 R 지움 · 사용자 R 둠
|
||||
const z = (s) => align.controlElevationAt(build(savedEdits), s);
|
||||
const vertices = [
|
||||
{ chainage_m: 40, elevation_m: z(40) },
|
||||
{ chainage_m: 120, elevation_m: z(120) + 1 },
|
||||
{ chainage_m: 200, elevation_m: z(200) },
|
||||
];
|
||||
const e7 = keep("freeAuto", tidy(free.buildFreeEdits(base, savedEdits, vertices, saved)));
|
||||
out.freeAuto = Object.keys(e7.curve_radii);
|
||||
out.freeUser = Object.keys(tidy(free.buildFreeEdits(base, userEdits, vertices, saved)).curve_radii);
|
||||
|
||||
// ⑧ 자유지정 range 단계 실시간 띠 · Esc = 시작만 취소
|
||||
{
|
||||
const stations = S;
|
||||
let edits = align.emptyEdits();
|
||||
const log = { closed: 0 };
|
||||
const draw = free.createFreeDraw({
|
||||
base: () => base,
|
||||
alignment: () => build(edits),
|
||||
edits: () => edits,
|
||||
apply: (next) => (edits = next),
|
||||
close: () => (log.closed += 1),
|
||||
changed: () => undefined,
|
||||
});
|
||||
draw.click(41, 0, stations);
|
||||
draw.hover({ chainageM: 117, elevationM: 0, stations });
|
||||
out.liveBand = draw.marks().band;
|
||||
out.liveEdges = draw.marks().edges;
|
||||
draw.hover({ chainageM: 13, elevationM: 0, stations });
|
||||
out.liveReverse = draw.marks().band;
|
||||
draw.hover(null);
|
||||
out.outside = { band: draw.marks().band, edges: draw.marks().edges };
|
||||
out.hintPending = draw.hint();
|
||||
out.escTaken = draw.key("Escape");
|
||||
out.afterEsc = { phase: draw.phase(), edges: draw.marks().edges, closed: log.closed };
|
||||
draw.click(41, 0, stations);
|
||||
draw.click(158, 0, stations);
|
||||
draw.hover({ chainageM: 260, elevationM: 0, stations });
|
||||
out.fixedBand = draw.marks().band;
|
||||
out.armedPhase = draw.phase();
|
||||
out.escArmed = draw.key("Escape");
|
||||
out.afterEscArmed = { phase: draw.phase(), closed: log.closed };
|
||||
}
|
||||
|
||||
// ⑩ 범위 띠 그림 — 가짜 DOM
|
||||
class El {
|
||||
constructor(tag) {
|
||||
this.tag = tag;
|
||||
this.attrs = {};
|
||||
this.children = [];
|
||||
this.parent = null;
|
||||
this.textContent = "";
|
||||
this.listeners = {};
|
||||
this.isConnected = true;
|
||||
this.classList = { toggle: () => undefined, add: () => undefined };
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attrs[name] = value;
|
||||
}
|
||||
append(...items) {
|
||||
items.forEach((item) => {
|
||||
item.parent = this;
|
||||
this.children.push(item);
|
||||
});
|
||||
}
|
||||
remove() {
|
||||
if (!this.parent) return;
|
||||
this.parent.children = this.parent.children.filter((c) => c !== this);
|
||||
this.parent = null;
|
||||
}
|
||||
addEventListener(type, fn, opts) {
|
||||
(this.listeners[type] ??= []).push(fn);
|
||||
opts?.signal?.addEventListener("abort", () => {
|
||||
this.listeners[type] = this.listeners[type].filter((f) => f !== fn);
|
||||
});
|
||||
}
|
||||
fire(type, event = {}) {
|
||||
(this.listeners[type] ?? []).forEach((fn) => fn(event));
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
}
|
||||
global.document = {
|
||||
createElementNS: (_ns, tag) => new El(tag),
|
||||
createElement: (tag) => new El(tag),
|
||||
};
|
||||
const shape = (svg) =>
|
||||
svg.children.map((c) =>
|
||||
c.tag === "text"
|
||||
? `text:${c.textContent}`
|
||||
: c.tag === "rect"
|
||||
? `rect:${c.attrs.x}+${c.attrs.width}`
|
||||
: `line:${c.attrs.x1}`,
|
||||
);
|
||||
{
|
||||
const x = (m) => m * 2;
|
||||
const svg = new El("svg");
|
||||
band.appendRangeBand(svg, x, 100, 120, 40);
|
||||
out.bandShape = shape(svg);
|
||||
const one = new El("svg");
|
||||
band.appendRangeBand(one, x, 100, 40, null);
|
||||
out.bandStart = shape(one);
|
||||
|
||||
const chart = new El("div");
|
||||
const scroller = new El("div");
|
||||
const geom = {
|
||||
chart,
|
||||
scroller,
|
||||
x,
|
||||
toChainage: (px) => px / 2,
|
||||
widthPx: 640,
|
||||
heightPx: 100,
|
||||
stations: S,
|
||||
};
|
||||
let start = null;
|
||||
const view = band.createRangeBandView((hover) => (start === null ? null : [start, hover]));
|
||||
view.mount(geom, true);
|
||||
out.viewIdle = chart.children.length;
|
||||
start = 40;
|
||||
view.redraw();
|
||||
out.viewStart = shape(chart.children[0]);
|
||||
chart.fire("mousemove", { clientX: 2 * 117 });
|
||||
out.viewHover = shape(chart.children[0]);
|
||||
chart.fire("mouseleave");
|
||||
out.viewLeave = shape(chart.children[0]);
|
||||
chart.fire("mousemove", { clientX: 2 * 61 });
|
||||
view.mount(geom, true); // 다시 그림(줌 · 스크롤 · 계획선 바뀜) 뒤에도 커서 자리 이어짐
|
||||
out.viewRemount = shape(chart.children[0]);
|
||||
view.mount(geom, false);
|
||||
out.viewOff = chart.children.length;
|
||||
}
|
||||
|
||||
// ⑨ 도구 상태 — [직선화] 부분 구간은 붉은 강조(selectedRuns) 아님 · Esc 취소 · 모드 유지
|
||||
{
|
||||
const picked = [];
|
||||
const t = tools.createProfileTools({
|
||||
onStraighten: (from, to) => picked.push([from, to]),
|
||||
onShift: () => undefined,
|
||||
onTilt: (run, delta) => picked.push(["tilt", run.fromM, run.toM, delta]),
|
||||
onResetToSaved: () => undefined,
|
||||
canResetToSaved: () => false,
|
||||
onUndo: () => undefined,
|
||||
onRedo: () => undefined,
|
||||
canUndo: () => false,
|
||||
canRedo: () => false,
|
||||
hasStraightRuns: () => true,
|
||||
onChanged: () => undefined,
|
||||
onModeChange: () => undefined,
|
||||
freeHint: () => "",
|
||||
mountOverlay: () => undefined,
|
||||
});
|
||||
const press = (label) => {
|
||||
const button = t.render().children.find((c) => c.textContent === label);
|
||||
button.fire("click", { stopPropagation: () => undefined });
|
||||
};
|
||||
const hintOf = () => t.render().children.find((c) => c.tag === "em")?.textContent ?? null;
|
||||
out.toolsNone = t.handleStationPick(40);
|
||||
press("직선화");
|
||||
t.handleStationPick(40);
|
||||
out.pending = { at: t.pendingStation(), hint: hintOf() };
|
||||
out.cancel = { taken: t.cancelPick(), pending: t.pendingStation(), mode: t.mode() };
|
||||
out.cancelAgain = t.cancelPick();
|
||||
t.handleRunPick({ fromM: 80, toM: 160, nodes: [80, 100, 120, 140, 160] });
|
||||
out.subPicked = { sub: t.subRun(), red: t.selectedRuns(), hint: hintOf() };
|
||||
press("▲");
|
||||
out.tiltCall = picked.at(-1);
|
||||
t.handleStationPick(60); // 새 시작 = 부분 구간 풂
|
||||
out.newStart = { pending: t.pendingStation(), sub: t.subRun() };
|
||||
t.handleStationPick(180);
|
||||
out.straightenCall = picked.at(-1);
|
||||
press("직선화"); // 끔
|
||||
press("쉬프트");
|
||||
t.handleRunPick({ fromM: 40, toM: 200, nodes: [40, 200] });
|
||||
out.shiftRed = { red: t.selectedRuns().length, sub: t.subRun(), cancel: t.cancelPick() };
|
||||
}
|
||||
|
||||
out.mirror = { base, cases: mirror };
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -514,7 +514,10 @@ async function run() {
|
||||
st("pipe:p28", 28, "pipe"),
|
||||
st("end:EP", 40, "end"),
|
||||
];
|
||||
const source = crossMod.pipelineCrossSource(C, screen, savedList, 7);
|
||||
// 초안 자리 관 측점(27 m) = 등록 열쇠(주인 id)로 저장 관 측점과 짝(457 · 차례 짝 없음)
|
||||
const source = crossMod.pipelineCrossSource(C, screen, savedList, 7, () =>
|
||||
savedList.map((s) => s.key),
|
||||
);
|
||||
const bare = crossMod.pipelineCrossSource(
|
||||
C,
|
||||
{ ...screen, cross_sections: screen.cross_sections.filter((x) => x.chainage_m !== 27) },
|
||||
@@ -534,6 +537,7 @@ async function run() {
|
||||
const viewFree = {
|
||||
grid20: source.input.byKey["grid:1"],
|
||||
pipeSpec: source.specs.get("pipe:p28") ?? null,
|
||||
pipeCarried: Boolean(source.input.byKey["pipe:p28"]),
|
||||
specsDiffer: source.input.specsKey !== bare.input.specsKey,
|
||||
firstStored: firstPvi(planlineOf(() => savedBase.basePvi)),
|
||||
firstShaken: firstPvi(planlineOf(undefined)),
|
||||
|
||||
@@ -37,9 +37,17 @@ const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
|
||||
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
});
|
||||
const seat = loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts", {
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
});
|
||||
const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", {
|
||||
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||
"../../common_util/common_util_route_follow": {},
|
||||
"../../common_util/common_util_structure_station": {
|
||||
PLUS_OWNER_PREFIX: "plus-",
|
||||
SAME_STATION_M: 0.005,
|
||||
},
|
||||
"./B05_Profile_Grade_Seat": seat,
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
"./B05_Profile_Grade_Carry": carry,
|
||||
"./B05_Profile_Sections_Stations": { SECTION_STATION_INTERVAL_M: 20 },
|
||||
|
||||
@@ -9,6 +9,7 @@ const STUBS = {
|
||||
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
|
||||
B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) },
|
||||
};
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
const cache = new Map();
|
||||
function load(full) {
|
||||
if (cache.has(full)) return cache.get(full).exports;
|
||||
@@ -18,6 +19,7 @@ function load(full) {
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box);
|
||||
const req = (r) => {
|
||||
if (CATALOG.isCatalogRequest(r)) return CATALOG; // 형상 얹기(Pipeline_Detail · 457)가 끌어감
|
||||
const name = r.split("/").pop();
|
||||
if (STUBS[name]) return STUBS[name];
|
||||
if (r.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${r.slice(6)}.ts`));
|
||||
|
||||
@@ -6,7 +6,8 @@ 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_station_key)$/;
|
||||
/(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_\w+)$/;
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
const STUBS = {
|
||||
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
|
||||
common_util_station: { stationLabel: (m) => String(m) },
|
||||
@@ -27,6 +28,9 @@ function load(full) {
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box);
|
||||
const req = (r) => {
|
||||
if (CATALOG.isCatalogRequest(r)) return CATALOG;
|
||||
if (r.endsWith(".json"))
|
||||
return JSON.parse(fs.readFileSync(path.resolve(path.dirname(full), r)));
|
||||
const name = r.split("/").pop();
|
||||
if (STUBS[name]) return STUBS[name];
|
||||
if (REAL.test(r)) return load(path.resolve(path.dirname(full), r + ".ts"));
|
||||
@@ -83,6 +87,13 @@ const draftStations = [
|
||||
const oldKeys = savedStations.map((s) => s.key);
|
||||
|
||||
const source = crossMod.pipelineCrossSource("p1", saved, savedStations, null);
|
||||
// 그림 자료 = 지금 현황(따라간 관 · 457) — 관 p35100 이 354 로
|
||||
const types = [{ type_id: "pipe", name: "배수관", options: [] }];
|
||||
const status = detailMod.shapeStatus(
|
||||
[{ id: "p35100", facility: "pipe", chainage_m: 354, options: { pipe_diameter_mm: "800" } }],
|
||||
[],
|
||||
types,
|
||||
);
|
||||
const draft = detailMod.draftSectionDetail(
|
||||
saved,
|
||||
{ stations: draftStations, longitudinal: { chainageM: [0, 400], z: [0, 0] } },
|
||||
@@ -90,6 +101,7 @@ const draft = detailMod.draftSectionDetail(
|
||||
null,
|
||||
source.specs,
|
||||
20,
|
||||
status,
|
||||
);
|
||||
const swapped = swapMod.swapCrossSections(saved, draftStations, oldKeys, 20).detail;
|
||||
const ids = (detail) => detail.cross_sections.map((s) => s.station_id);
|
||||
@@ -106,6 +118,7 @@ const added = detailMod.draftSectionDetail(
|
||||
null,
|
||||
source.specs,
|
||||
20,
|
||||
status,
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -71,6 +71,7 @@ STUBS["B04_PreProcess_Api_Fetch"] = {
|
||||
};
|
||||
STUBS["B05_Profile_Api_Structures"] = {
|
||||
flushPendingStructures: async () => failing("structures"),
|
||||
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459) 안 돎
|
||||
};
|
||||
STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined };
|
||||
STUBS["B05_Profile_Api_Fetch"] = {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/* 457 헬퍼 — 노선 바뀜 뒤 초안 카드(묶음 새 상세) 그림 · 이름 · 시설 맥락 = 지금 현황을 새 측점에 다시 얹은 것.
|
||||
* 진짜 TS(`design_pipeline_cross` · `B05_Profile_Pipeline_Detail` · 측점 · 따라감 · 형상 얹기) · 세션 창구만 가짜.
|
||||
* 재현 E · F(시점 당김) · G(세션에서 놓은 BOX) · H(관 둘 엇갈려 옮김) — 결과 = 표준출력 JSON. 짝 = `test_457_draft_card.py`. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
const STUBS = {
|
||||
b_page_state: { readState: () => null },
|
||||
B06_Section_Section_Canon: { savedDesigns: () => null },
|
||||
B06_Section_UI_Standard_Panel: {
|
||||
effectiveStandardCross: () => ({ road_width_m: 3 }),
|
||||
readRockBoundaryDefault: () => null,
|
||||
},
|
||||
B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) },
|
||||
};
|
||||
const cache = new Map();
|
||||
function resolveTs(base) {
|
||||
for (const c of [`${base}.ts`, base]) if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
||||
throw new Error(`없음: ${base}`);
|
||||
}
|
||||
function load(full) {
|
||||
if (cache.has(full)) return cache.get(full).exports;
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box);
|
||||
const req = (r0) => {
|
||||
if (CATALOG.isCatalogRequest(r0)) return CATALOG;
|
||||
const r = r0.replace(/\.js$/, "");
|
||||
const stub = STUBS[r.split("/").pop()];
|
||||
if (stub) return stub;
|
||||
if (r.endsWith(".json"))
|
||||
return JSON.parse(fs.readFileSync(path.join(path.dirname(full), r), "utf8"));
|
||||
if (r.startsWith("@util/")) return load(resolveTs(path.join(ROOT, "common_util", r.slice(6))));
|
||||
if (r.startsWith(".")) return load(resolveTs(path.join(path.dirname(full), r)));
|
||||
return require(r);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
return box.exports;
|
||||
}
|
||||
const P = (...a) => path.join(ROOT, ...a);
|
||||
const follow = load(P("common_util", "common_util_route_follow.ts"));
|
||||
const SS = load(P("B05_Profile", "logic", "B05_Profile_Sections_Stations.ts"));
|
||||
const AT = load(P("common_util", "common_util_structure_attach.ts"));
|
||||
const ST = load(P("common_util", "common_util_structure_station.ts"));
|
||||
const CS = load(P("common_util", "common_util_culvert_sets.ts"));
|
||||
const PD = load(P("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts"));
|
||||
const CR = load(P("A00_Common", "design", "design_pipeline_cross.ts"));
|
||||
|
||||
const def = JSON.parse(
|
||||
fs.readFileSync(P("resources", "master_template", "구조물정의.json"), "utf8"),
|
||||
);
|
||||
const types = def.types
|
||||
.filter((t) => t.enabled !== false)
|
||||
.map((t) => ({
|
||||
type_id: t.type_id,
|
||||
group: t.group,
|
||||
placement: t.placement,
|
||||
managed_by: t.managed_by,
|
||||
name: t.name,
|
||||
options: (t.options || []).map((o) => ({ key: o.key, label: o.label, default: o.default })),
|
||||
}));
|
||||
const SHAPES = AT.SHAPE_KEYS;
|
||||
const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`;
|
||||
const kindOf = (s) =>
|
||||
s.kind === "end"
|
||||
? s.key.endsWith("EP")
|
||||
? "ep"
|
||||
: "bp"
|
||||
: s.kind === "grid"
|
||||
? "regular"
|
||||
: s.kind === "curve"
|
||||
? "curve"
|
||||
: "irregular";
|
||||
const ground = (s) => ({
|
||||
...s,
|
||||
ground: { centerZ: 100, left: [{ offsetM: 5, z: 101 }], right: [{ offsetM: 5, z: 99 }] },
|
||||
});
|
||||
const sectionsFrom = (S) =>
|
||||
S.map((s) => ({
|
||||
station_id: idOf(s.chainageM),
|
||||
chainage_m: s.chainageM,
|
||||
label: "",
|
||||
kind: kindOf(s),
|
||||
center_x: s.x,
|
||||
center_y: s.y,
|
||||
samples: [],
|
||||
design: { design_elevation_m: 101 },
|
||||
}));
|
||||
const xy = (L) => L.map(([x, y]) => ({ x, y }));
|
||||
function at(L, ch) {
|
||||
let walked = 0;
|
||||
for (let i = 1; i < L.length; i += 1) {
|
||||
const [a, b] = [L[i - 1], L[i]];
|
||||
const span = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
if (walked + span >= ch) {
|
||||
const t = (ch - walked) / span;
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
}
|
||||
walked += span;
|
||||
}
|
||||
return L[L.length - 1];
|
||||
}
|
||||
const pipe = (id, facility, ch, L, options) => {
|
||||
const [x, y] = at(L, ch);
|
||||
return { id, facility, chainage_m: ch, x, y, options };
|
||||
};
|
||||
const wall = (L) => {
|
||||
const [x, y] = at(L, 70);
|
||||
return {
|
||||
structure_id: "w1",
|
||||
type_id: "retaining_wall",
|
||||
chainage_m: 70,
|
||||
start_m: 60,
|
||||
end_m: 80,
|
||||
x,
|
||||
y,
|
||||
options: { height_m: 2, side: "left" },
|
||||
};
|
||||
};
|
||||
/** 측점 그림 지문 — 얹힌 형상 열쇠 · 그 누가거리(구간이면 시작~끝). */
|
||||
const shapesOf = (s) =>
|
||||
SHAPES.filter((k) => s[k] !== undefined)
|
||||
.map((k) => {
|
||||
const v = s[k];
|
||||
const c = Array.isArray(v)
|
||||
? v.map((x) => `${x.start_m ?? x.chainage_m ?? ""}~${x.end_m ?? ""}`).join("/")
|
||||
: v && (v.chainage_m ?? `${v.start_m ?? ""}~${v.end_m ?? ""}`);
|
||||
return `${k}@${c}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
/** 정답 = 새 측점(구조물 없는 꼴)에 지금 현황(따라간 관 · 구조물)을 얹은 그림 · 측점 계획 이름(캐시 `withDraftShapes`). */
|
||||
function truthOf(S2, pipes, structures) {
|
||||
const bare = S2.map((s) => ({ chainage_m: s.chainageM }));
|
||||
AT.attachShapes(bare, pipes, structures, types);
|
||||
const planned = ST.planStations(pipes, structures, types, CS.registryDefaults(types));
|
||||
return bare.map((s) => ({
|
||||
shapes: shapesOf(s),
|
||||
name: planned.find((p) => Math.abs(p.chainage_m - s.chainage_m) < 0.005)?.title ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function scenario(L1, L2, saved, now, opts = {}) {
|
||||
const S1 = SS.buildStations(L1, [], saved.pipes, saved.structures, { types, intervalM: 20 });
|
||||
// 저장 상세(서버) = 저장 측점에 저장 현황 그림 · 초안 둔 채 열기면 화면 상세가 초안 자리(`canon`)
|
||||
const Scanon = opts.canon ?? S1;
|
||||
const detail = {
|
||||
longitudinal: { stations: [], design_profiles: [], length_m: 0, samples: [] },
|
||||
cross_sections: sectionsFrom(Scanon),
|
||||
};
|
||||
AT.attachShapes(
|
||||
detail.cross_sections,
|
||||
opts.canonPipes ?? saved.pipes,
|
||||
opts.canonStructures ?? saved.structures,
|
||||
types,
|
||||
);
|
||||
const pF = follow.projectPipePoints(now.pipes, xy(L2));
|
||||
const sF = follow.followStructures(now.structures, xy(L2), undefined, 20).map((m) => m.item);
|
||||
const S2 = SS.buildStations(L2, [], pF, sF, { types, intervalM: 20 }).map(ground);
|
||||
const keysOf = opts.keys ? () => opts.keys : undefined;
|
||||
const source = CR.pipelineCrossSource("p457", detail, S1, 7, keysOf);
|
||||
// 노선 초안 동안 등록 목록이 새 노선 것으로 바뀜 — 처음 본 열쇠 그대로여야 함
|
||||
const again = opts.keys
|
||||
? CR.pipelineCrossSource("p457", detail, S1, 7, () => [...opts.keys].reverse())
|
||||
: source;
|
||||
const status = PD.shapeStatus
|
||||
? PD.shapeStatus(pF, sF, types)
|
||||
: { pipes: pF, structures: sF, types };
|
||||
const card = PD.draftSectionDetail(
|
||||
detail,
|
||||
{ stations: S2, longitudinal: { chainageM: [0], z: [0] } },
|
||||
{},
|
||||
null,
|
||||
again.specs,
|
||||
20,
|
||||
status,
|
||||
);
|
||||
const truth = truthOf(S2, pF, sF);
|
||||
const shaped = PD.crossWithShapes ? PD.crossWithShapes(source.input, S2, status) : source.input;
|
||||
const ctx = (key) => {
|
||||
const e = shaped.byKey[key] ?? {};
|
||||
return [Boolean(e.paved), Boolean(e.tilted), e.tiltPct ?? null, e.surfaceDropM ?? 0];
|
||||
};
|
||||
return {
|
||||
rows: S2.map((s, i) => ({
|
||||
key: s.key,
|
||||
m: s.chainageM,
|
||||
card: shapesOf(card.cross_sections[i]),
|
||||
truth: truth[i].shapes,
|
||||
name: card.cross_sections[i].structure ?? null,
|
||||
truthName: truth[i].name,
|
||||
id: card.cross_sections[i].station_id,
|
||||
ctx: ctx(s.key),
|
||||
})),
|
||||
canonIds: Object.fromEntries(
|
||||
Scanon.map((s, i) => [s.key, detail.cross_sections[i].station_id]),
|
||||
),
|
||||
sourceKeys: Object.fromEntries([...source.specs].map(([k, v]) => [k, v.station_id])),
|
||||
specFields: [...new Set([...source.specs.values()].flatMap((v) => Object.keys(v)))],
|
||||
};
|
||||
}
|
||||
|
||||
const L1 = [
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[200, 60],
|
||||
];
|
||||
const P1 = [
|
||||
pipe("p1", "pipe", 47, L1, { pipe_diameter_mm: "800" }),
|
||||
pipe("fp", "ford_pavement", 125, L1, { ford_width_m: 12 }),
|
||||
];
|
||||
const facilities = { pipes: P1, structures: [wall(L1)] };
|
||||
const out = {};
|
||||
out.E = scenario(
|
||||
L1,
|
||||
[
|
||||
[-13, 0],
|
||||
[100, 0],
|
||||
[200, 60],
|
||||
],
|
||||
facilities,
|
||||
facilities,
|
||||
);
|
||||
out.F = scenario(
|
||||
L1,
|
||||
[
|
||||
[-7, 0],
|
||||
[100, 0],
|
||||
[200, 60],
|
||||
],
|
||||
facilities,
|
||||
facilities,
|
||||
);
|
||||
out.G = scenario(
|
||||
L1,
|
||||
[
|
||||
[-7, 0],
|
||||
[100, 0],
|
||||
[200, 60],
|
||||
],
|
||||
facilities,
|
||||
{
|
||||
pipes: [...P1, pipe("p9", "box_culvert", 90, L1, { body_width_m: 2 })],
|
||||
structures: [wall(L1)],
|
||||
},
|
||||
);
|
||||
// H — 세션에서 관 둘을 엇갈려 옮김(저장 전) · 초안 둔 채 열기(화면 상세 = 옮긴 자리) → 시점 +7
|
||||
{
|
||||
const saved = [
|
||||
pipe("p1", "pipe", 47, L1, { pipe_diameter_mm: "800" }),
|
||||
pipe("p2", "box_culvert", 90, L1, { body_width_m: 2 }),
|
||||
];
|
||||
const moved = [
|
||||
pipe("p1", "pipe", 110, L1, { pipe_diameter_mm: "800" }),
|
||||
pipe("p2", "box_culvert", 30, L1, { body_width_m: 2 }),
|
||||
];
|
||||
const Snow = SS.buildStations(L1, [], moved, [], { types, intervalM: 20 });
|
||||
out.H = scenario(
|
||||
L1,
|
||||
[
|
||||
[-7, 0],
|
||||
[100, 0],
|
||||
[200, 60],
|
||||
],
|
||||
{ pipes: saved, structures: [] },
|
||||
{ pipes: moved, structures: [] },
|
||||
{ canon: Snow, canonPipes: moved, keys: Snow.map((s) => s.key) },
|
||||
);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,85 @@
|
||||
/* 459 노선 바뀜 — 진짜 TS(`logic/B05_Profile_Pipeline_Grade` 옮긴 시설 측점 · 427 세움 · planline 단계 ·
|
||||
* `common_util_route_follow` 구조물 맞춤 · 추가 측점 따라감)를 Node 로 돌림.
|
||||
* 입력 argv[2] JSON [{ op, ... }, …] · 표준출력 = 결과 한 벌씩.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.join(ROOT, file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (CATALOG.isCatalogRequest(request)) return CATALOG;
|
||||
if (request.endsWith(".json"))
|
||||
return JSON.parse(fs.readFileSync(path.join(path.dirname(full), request), "utf8"));
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`));
|
||||
throw new Error(`stub 없음: ${request} (${file})`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts");
|
||||
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts");
|
||||
const follow = loadTs("common_util/common_util_route_follow.ts");
|
||||
const xy = (line) => line.map(([x, y]) => ({ x, y }));
|
||||
|
||||
const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const out = cases.map((item) => {
|
||||
switch (item.op) {
|
||||
case "moved":
|
||||
return grade.routeMovedStations(
|
||||
item.stations,
|
||||
xy(item.oldXy),
|
||||
xy(item.newXy),
|
||||
new Set(item.moved ?? []),
|
||||
);
|
||||
case "planline": {
|
||||
// 계산 묶음 planline 단계 그대로 — 저장 기준(storedPvi) · 새 지반 · 측점 · follow 출력
|
||||
const stage = grade.createPlanlineStage({
|
||||
savedBase: () => item.base,
|
||||
savedLine: () => item.oldXy,
|
||||
storedPvi: () => item.base.basePvi,
|
||||
});
|
||||
const ctxOf = (stations, followed) => ({
|
||||
inputs: { route: {}, alignment: null, pipes: item.pipes ?? null, structures: null },
|
||||
out: {
|
||||
route: { line: item.newXy },
|
||||
ground: {
|
||||
stations: stations.map((s) => ({ ...s, ground: null })),
|
||||
longitudinal: { chainageM: item.chainage, z: item.ground },
|
||||
},
|
||||
follow: followed,
|
||||
},
|
||||
});
|
||||
const result = stage(ctxOf(item.stations, item.followed ?? null));
|
||||
// 시설 없는 같은 측점 = 옮겨 붙인 꼭짓점(427 세움 전) — 파이썬 `seat_route_moved` 입력
|
||||
const plain = stage(
|
||||
ctxOf(
|
||||
item.stations.map((s) => ({ ...s, kind: "grid", id: null })),
|
||||
null,
|
||||
),
|
||||
);
|
||||
return { basePvi: result.basePvi, carried: plain.basePvi };
|
||||
}
|
||||
case "carry":
|
||||
return carry.carryBase(xy(item.oldXy), xy(item.newXy), item.oldBase, item.groundPvi);
|
||||
case "rebase":
|
||||
return follow.rebaseStructureDraft(item.draft, item.before, item.after);
|
||||
case "plus":
|
||||
return follow.followPlusStations(item.list, xy(item.from), xy(item.to));
|
||||
default:
|
||||
throw new Error(`op 없음: ${item.op}`);
|
||||
}
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -59,9 +59,15 @@ const STUBS = {
|
||||
"../../B04_PreProcess/B04_PreProcess_Api_Fetch": {
|
||||
fetchDetailPipePoints: async () => ({ pipe_points: [] }),
|
||||
},
|
||||
"../../common_util/common_util_route_follow": { rebasePipeDraft: (draft) => draft },
|
||||
"../../common_util/common_util_route_follow": {
|
||||
rebasePipeDraft: (draft) => draft,
|
||||
rebaseStructureDraft: (draft) => draft,
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Structures": {
|
||||
flushPendingStructures: () => record("structures", failStructures)(),
|
||||
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459)는 안 돎
|
||||
writePendingStructures: () => undefined,
|
||||
fetchStructures: async () => ({ structures: [] }),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_PlusStations": {
|
||||
flushPlusStations: () => record("plusStations")(),
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""271-1~3 B05 종단 [직선화] 측점 고름 · 끝 R 기본 L · [직선화] · [자유지정] 실시간 띠.
|
||||
|
||||
요구 271 · PLAN 461.
|
||||
|
||||
계산(`logic/B05_Profile_UI_Profile_Straighten.ts` · `_Tools.ts` · `design_profile_free.ts` ·
|
||||
`design_profile_range_band.ts` · `_CurveLaw.ts`)은 Node 헬퍼(`helper_271_straighten_pick.cjs`)가
|
||||
트랜스파일해 돌리고, 파이썬 짝(`build_alignment` · `vertical_curve_shortfalls`)은 같은 base · 편집으로
|
||||
곡선 자리 · R · L · 미달 경고가 같은지 거울로 본다.
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_271_straighten_pick.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
|
||||
AlignmentPolicy,
|
||||
build_alignment,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import ( # noqa: E402
|
||||
SHORTFALL_TOLERANCE_M,
|
||||
vertical_curve_shortfalls,
|
||||
)
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_271_straighten_pick.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _at(curves, chainage):
|
||||
return next((c for c in curves if abs(c["at"] - chainage) < 1e-6), None)
|
||||
|
||||
|
||||
def test_직선화_끝_기본_L_사이_곡선_없음(out):
|
||||
curves = out["straight"]["curves"]
|
||||
assert not [c for c in curves if 40 < c["at"] < 200]
|
||||
assert _at(curves, 40)["l"] == pytest.approx(20) and _at(curves, 200)["l"] == pytest.approx(20)
|
||||
assert out["straight"]["radii"] == []
|
||||
|
||||
|
||||
def test_이미_직선_범위_부분_구간_반대_방향_같음(out):
|
||||
assert out["sub"] == {"fromM": 80, "toM": 160, "nodes": [80, 100, 120, 140, 160]}
|
||||
assert out["subReverse"] == out["sub"]
|
||||
assert out["subBent"] is None and out["subMixed"] is None
|
||||
assert out["subOneSpan"] is None # 사이 꼭짓점 없음 = 꺾을 곳 없음
|
||||
|
||||
|
||||
def test_부분_구간_꺾기_양_끝_L20_거짓_미달_없음(out):
|
||||
curves = out["tiltSub"]["curves"]
|
||||
for at in (80, 120, 160):
|
||||
assert _at(curves, at)["l"] == pytest.approx(20, abs=1e-3)
|
||||
assert not [c for c in curves if 80 < c["at"] < 160 and c["at"] != 120]
|
||||
assert out["tiltSub"]["shortfalls"] == []
|
||||
|
||||
|
||||
def test_법정_판정_오차_짝(out):
|
||||
assert out["tolerance"] == SHORTFALL_TOLERANCE_M == 1e-3
|
||||
assert out["nearMiss"] == []
|
||||
assert len(out["realMiss"]) == 1
|
||||
near = [{"chainage_m": 120, "l_m": 19.99998, "r_m": 800}]
|
||||
assert vertical_curve_shortfalls(near, 100, 20) == []
|
||||
real = [{"chainage_m": 120, "l_m": 19.9, "r_m": 800}]
|
||||
assert vertical_curve_shortfalls(real, 100, 20) == out["realMiss"]
|
||||
|
||||
|
||||
def test_끝_저장_자동_R_지움_사용자_R_둠(out):
|
||||
assert out["autoEnds"]["radii"] == []
|
||||
curves = out["autoEnds"]["curves"]
|
||||
assert _at(curves, 40)["l"] == pytest.approx(20) and _at(curves, 200)["l"] == pytest.approx(20)
|
||||
assert out["userEnd"]["radii"] == {"40.000": 600}
|
||||
assert out["noAuto"] == ["40.000", "200.000"]
|
||||
|
||||
|
||||
def test_BP_EP_곡선_없음(out):
|
||||
assert out["bpEp"] == []
|
||||
assert out["tiltBp"][:2] == [40, 80] # 시점 0 엔 곡선 없음 · 가운데 · 끝만
|
||||
|
||||
|
||||
def test_곡선_섞인_범위_안쪽_R_지움_양_끝_L20(out):
|
||||
assert out["mixed"]["radii"] == []
|
||||
curves = out["mixed"]["curves"]
|
||||
assert not [c for c in curves if 100 < c["at"] < 260]
|
||||
assert _at(curves, 100)["l"] == pytest.approx(20) and _at(curves, 260)["l"] == pytest.approx(20)
|
||||
|
||||
|
||||
def test_자유지정_끝_자동_R_지움_사용자_R_둠(out):
|
||||
assert out["freeAuto"] == ["120.000"]
|
||||
assert out["freeUser"] == ["40.000", "120.000"]
|
||||
|
||||
|
||||
def test_자유지정_범위_실시간_띠_Esc_시작만(out):
|
||||
assert out["liveBand"] == [40, 120] and out["liveEdges"] == [40, 120]
|
||||
assert out["liveReverse"] == [20, 40]
|
||||
assert out["outside"] == {"band": None, "edges": [40]} # 그래프 밖 = 시작선만
|
||||
assert "Esc 시작 취소" in out["hintPending"]
|
||||
assert out["escTaken"] and out["afterEsc"] == {"phase": "range", "edges": [], "closed": 0}
|
||||
assert out["fixedBand"] == [40, 160] and out["armedPhase"] == "armed" # 둘째 클릭 뒤 고정
|
||||
assert out["escArmed"] and out["afterEscArmed"] == {"phase": "range", "closed": 1}
|
||||
|
||||
|
||||
def test_범위_띠_그림(out):
|
||||
assert out["bandShape"] == ["rect:80+160", "text:40.0–120.0m · 80.0m", "line:80", "line:240"]
|
||||
assert out["bandStart"] == ["line:80"]
|
||||
assert out["viewIdle"] == 0 and out["viewOff"] == 0
|
||||
assert out["viewStart"] == ["line:80"]
|
||||
assert out["viewHover"] == out["bandShape"]
|
||||
assert out["viewLeave"] == ["line:80"]
|
||||
assert out["viewRemount"] == ["rect:80+40", "text:40.0–60.0m · 20.0m", "line:80", "line:120"]
|
||||
|
||||
|
||||
def test_도구_직선화_부분_구간_붉은_강조_아님(out):
|
||||
assert out["toolsNone"] is False
|
||||
assert out["pending"] == {
|
||||
"at": 40,
|
||||
"hint": "40.0m 선택 — 두 번째 측점을 고르세요 (Esc 시작 취소)",
|
||||
}
|
||||
assert out["cancel"] == {"taken": True, "pending": None, "mode": "straighten"}
|
||||
assert out["cancelAgain"] is False
|
||||
assert out["subPicked"]["red"] == [] and out["subPicked"]["sub"]["fromM"] == 80
|
||||
assert out["subPicked"]["hint"] == "이미 직선 — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선"
|
||||
assert out["tiltCall"] == ["tilt", 80, 160, 0.1]
|
||||
assert out["newStart"] == {"pending": 60, "sub": None}
|
||||
assert out["straightenCall"] == [60, 180]
|
||||
assert out["shiftRed"] == {"red": 1, "sub": None, "cancel": False} # [쉬프트] 붉은 강조 그대로
|
||||
|
||||
|
||||
def test_파이썬_거울_곡선_자리_R_L_미달(out):
|
||||
base = out["mirror"]["base"]
|
||||
policy = AlignmentPolicy.from_dict(base["policy"])
|
||||
for case in out["mirror"]["cases"]:
|
||||
got = build_alignment(
|
||||
base_s=np.array([n["chainage_m"] for n in base["basePvi"]]),
|
||||
base_z=np.array([n["elevation_m"] for n in base["basePvi"]]),
|
||||
chainage=np.array(base["chainage"]),
|
||||
ground=np.array(base["ground"]),
|
||||
stations=base["stations"],
|
||||
policy=policy,
|
||||
edits=case["edits"],
|
||||
)
|
||||
py = [[c["chainage_m"], c["r_m"], c["l_m"]] for c in got["curves"]]
|
||||
ts = case["curves"]
|
||||
assert [c[0] for c in py] == pytest.approx([c[0] for c in ts], abs=1e-6), case["name"]
|
||||
assert [c[1] for c in py] == pytest.approx([c[1] for c in ts], rel=1e-6), case["name"]
|
||||
assert [c[2] for c in py] == pytest.approx([c[2] for c in ts], abs=1e-6), case["name"]
|
||||
assert not [w for w in got["warnings"] if "미달" in w], case["name"]
|
||||
|
||||
|
||||
def test_배선():
|
||||
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
|
||||
assert "tools.handleStationPick(station ?? chainageM)" in panel
|
||||
assert "straightSubRun(alignment, fromM, toM)" in panel
|
||||
assert "straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())" in panel
|
||||
assert "autoRadii: ctx.savedRadii" in panel
|
||||
assert 'window.addEventListener("keydown", onEscape, true)' in panel
|
||||
assert "createRangeBandView(" in panel
|
||||
view = _read("A00_Common/design/design_profile_free_view.ts")
|
||||
assert "appendRangeBand(svg, x, heightPx" in view
|
||||
css = _read("B05_Profile/B05_Profile_UI_Style_Table.css")
|
||||
rule = css[css.index(".b05-profile-freemark__span {") :]
|
||||
rule = rule[: rule.index("}")]
|
||||
assert "--color-primary" in rule and "--color-surface" in rule # 밝음 · 어두움 토큰
|
||||
|
||||
|
||||
def test_줄_수():
|
||||
for relative in (
|
||||
"B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts",
|
||||
"B05_Profile/B05_Profile_UI_Profile_Tools.ts",
|
||||
"B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts",
|
||||
"A00_Common/design/logic/design_profile_free.ts",
|
||||
"A00_Common/design/design_profile_free_view.ts",
|
||||
"A00_Common/design/design_profile_range_band.ts",
|
||||
):
|
||||
assert len(_read(relative).splitlines()) <= 700
|
||||
@@ -170,10 +170,9 @@ def test_365_5_들어온_화면_무관(r):
|
||||
g = v["grid20"]
|
||||
# 설계 칸 = 세션 저장 상세(화면 상세가 굳힌 선택 false 대신 저장값) · 옛 측구 결과도 실음
|
||||
assert g["ditchChoice"] is None and g["ditchEnabled"] is False
|
||||
assert v["pipeSpec"] == {
|
||||
"culvert": {"type": "pipe", "diameter_m": 0.8}
|
||||
} # 초안 자리 관 = 차례 짝
|
||||
assert v["specsDiffer"] # 제원이 바뀌면 입력 지문도 바뀜(고리가 다시 셈)
|
||||
# 초안 자리 관 = 등록 열쇠 짝 · 넘기는 것 = 카드 번호 · 사용자 칸만(그림은 새 자리에서 · 457)
|
||||
assert v["pipeSpec"] == {} and v["pipeCarried"] # 짝 있음(시험 상세는 카드 번호 없음)
|
||||
assert v["specsDiffer"] # 짝지은 측점이 바뀌면 입력 지문도 바뀜(고리가 다시 셈)
|
||||
assert v["firstStored"] == v["savedFirst"] and v["firstShaken"] != v["savedFirst"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""457 노선 바뀜 — 초안 카드 그림 · 이름 · 횡단 맥락 = 지금 현황을 새 측점에 다시 얹음(요구 270).
|
||||
|
||||
재현 E · F(시점 당김 +13 · +7 — 배관 · 물넘이포장 · 옹벽) · G(세션에서 놓은 BOX) ·
|
||||
H(관 둘 엇갈려 옮김 · 초안 둔 채 열기).
|
||||
① 모든 측점 카드 그림 · 이름 = 새 측점에 따라간 관 · 구조물을 얹은 것(캐시 `withDraftShapes` 한 벌)
|
||||
② 시설마다 제 측점 하나 · 옛 측점 그림을 옮기지 않음 · 카드 번호만 열쇠로 이어 받음
|
||||
③ 시설 맥락(포장 · 기울임 · 하강) = 다시 얹은 그림에서 · 짝 = 등록 열쇠(차례 짝 없음 · 엇갈림 0)
|
||||
④ 가게 쓰기 = 지금 가게 값과 견줌(되돌리기 뒤 같은 조작도 씀)
|
||||
TS 셈은 `helper_457_draft_card.cjs`.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_457_draft_card.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).with_name("helper_457_draft_card.cjs")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def _row(rows: list, key: str) -> dict:
|
||||
return next(r for r in rows if r["key"] == key)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["E", "F", "G", "H"])
|
||||
def test_카드_그림_이름이_새_자리_현황(got, name) -> None:
|
||||
rows = got[name]["rows"]
|
||||
bad = [(r["key"], r["card"], r["truth"]) for r in rows if r["card"] != r["truth"]]
|
||||
assert not bad
|
||||
names = [(r["key"], r["name"], r["truthName"]) for r in rows if r["name"] != r["truthName"]]
|
||||
assert not names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["E", "F", "G", "H"])
|
||||
def test_카드_번호만_이어_받음(got, name) -> None:
|
||||
assert got[name]["specFields"] == ["station_id"]
|
||||
|
||||
|
||||
def test_E_물넘이포장은_제_측점에만(got) -> None:
|
||||
rows = got["E"]["rows"]
|
||||
fp = _row(rows, "pipe:fp")
|
||||
assert "ford_pavement@" in fp["card"]
|
||||
assert fp["ctx"][:2] == [True, True] # 포장 · 기울임 = 새 자리 그림
|
||||
no6 = _row(rows, "grid:6") # 120 m — 옛 물넘이 범위(119~131) · 새 범위(132~144) 밖
|
||||
assert "ford_pavement" not in no6["card"] and no6["name"] is None
|
||||
assert no6["ctx"] == [False, False, None, 0]
|
||||
pipes = [r for r in rows if "culvert@" in r["card"]]
|
||||
assert [r["key"] for r in pipes] == ["pipe:p1"] # 배관 그림 = 관 측점 하나(No.3 아님)
|
||||
|
||||
|
||||
def test_G_세션에서_놓은_BOX_카드(got) -> None:
|
||||
box = _row(got["G"]["rows"], "pipe:p9")
|
||||
assert "box@" in box["card"] and box["name"]
|
||||
|
||||
|
||||
def test_H_엇갈려_옮긴_관_짝(got) -> None:
|
||||
h = got["H"]
|
||||
p1 = _row(h["rows"], "pipe:p1")
|
||||
p2 = _row(h["rows"], "pipe:p2")
|
||||
assert "culvert@" in p1["card"] and "box@" not in p1["card"]
|
||||
assert "box@" in p2["card"] and "culvert@" not in p2["card"]
|
||||
# 카드 번호 = 같은 관의 화면 상세 번호(등록 열쇠 짝 · 목록이 바뀌어도 처음 본 열쇠)
|
||||
assert h["sourceKeys"]["pipe:p1"] == h["canonIds"]["pipe:p1"]
|
||||
assert h["sourceKeys"]["pipe:p2"] == h["canonIds"]["pipe:p2"]
|
||||
assert p1["id"] == h["canonIds"]["pipe:p1"]
|
||||
|
||||
|
||||
def test_가게_쓰기는_지금_가게_값과_견줌() -> None:
|
||||
page = (ROOT / "A00_Common" / "design" / "design_pipeline_page.ts").read_text(encoding="utf-8")
|
||||
body = page[page.index("function writeFacilities") :]
|
||||
body = body[: body.index("\n }\n")]
|
||||
assert 'same(store.get("pipes"), next.pipes)' in body
|
||||
assert 'same(store.get("structures"), next.structures)' in body
|
||||
assert "written" not in page
|
||||
@@ -0,0 +1,310 @@
|
||||
"""459 노선 바뀜(요구 270) — [저장] 구조물 맞춤 · 추가 측점 따라감 · 관 꼭짓점 세움 · 관 따라감 선.
|
||||
|
||||
- 옮긴 시설 측점(바뀐 구간 · 사용자가 옮김)에 427 꼭짓점 다시 세움 — 초안 planline(TS) ↔
|
||||
서버 `_carry_profile`(`route_moved_stations` · `seat_route_moved`) 같은 값 · 같은 자리로
|
||||
따라간 시설은 옛 높이.
|
||||
- [저장] 구조물 초안 = 서버 따라감 위에(`rebaseStructureDraft` · 관 `rebasePipeDraft` 짝).
|
||||
- 추가 측점 좌표 따라감 TS `followPlusStations` ↔ 파이썬 `follow_plus_stations`.
|
||||
- 서버 관 따라감 선 = 구조물과 같은 `points`(사용 범위 자름) · 좌표 없는 옛 관은 옛 노선
|
||||
자리 먼저.
|
||||
helper_459_route_seat.cjs 가 진짜 TS 를 Node 로 돌림.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
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_Grade import GradeDesignOptions # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
|
||||
AlignmentPolicy,
|
||||
build_curves,
|
||||
evaluate,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_ground_following_profile,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Plus_Stations import ( # noqa: E402
|
||||
follow_plus_stations,
|
||||
plus_chainages,
|
||||
save_plus_stations,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402
|
||||
route_moved_stations,
|
||||
seat_route_moved,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import PipePoint # noqa: E402
|
||||
|
||||
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]]
|
||||
NEW_LEN = 200.0 + math.hypot(100.0, 60.0) + 120.0
|
||||
|
||||
|
||||
def _old_ground(s: float) -> float:
|
||||
return 100.0 + 0.08 * s + 1.5 * math.sin(s / 7.0)
|
||||
|
||||
|
||||
def _new_ground(s: float) -> float:
|
||||
"""200 m 까지 옛 선과 같은 땅 · 뒤는 다른 땅(노선이 옮겨 감)."""
|
||||
if s <= 200.0:
|
||||
return _old_ground(s)
|
||||
return _old_ground(200.0) + 0.05 * (s - 200.0) + 0.8 * math.cos((s - 200.0) / 9.0)
|
||||
|
||||
|
||||
def _old_alignment() -> dict:
|
||||
samples = [
|
||||
{"chainage_m": float(s), "elevation_m": round(_old_ground(s), 6), "valid": True}
|
||||
for s in range(0, 401)
|
||||
]
|
||||
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
|
||||
stations.append({"chainage_m": 120.0, "kind": "irregular", "structure": "파형강관 D800"})
|
||||
stations.sort(key=lambda row: row["chainage_m"])
|
||||
options = GradeDesignOptions(
|
||||
max_grade_pct=14.0,
|
||||
max_reverse_grade_pct=5.0,
|
||||
min_vertical_radius_m=100.0,
|
||||
min_curve_length_m=20.0,
|
||||
min_tangent_length_m=20.0,
|
||||
vertical_curve_skip_delta_pct=0.5,
|
||||
design_speed_kph=20,
|
||||
)
|
||||
alignment, _ = design_ground_following_profile(
|
||||
{"samples": samples, "stations": stations}, options, station_interval_m=20.0
|
||||
)
|
||||
return alignment
|
||||
|
||||
|
||||
def _stations(pipe_at: float = 120.0) -> list[dict]:
|
||||
"""새 노선 측점(TS 꼴) — 격자 · 끝 · 관 둘 · 구조물 하나 · 추가 측점 하나."""
|
||||
rows = [{"chainageM": float(s), "kind": "grid", "id": None} for s in range(0, 421, 20)]
|
||||
rows.append({"chainageM": round(NEW_LEN, 3), "kind": "end", "id": None})
|
||||
rows += [
|
||||
{"chainageM": pipe_at, "kind": "pipe", "id": "p120"},
|
||||
{"chainageM": 251.0, "kind": "structure", "id": "s251"},
|
||||
{"chainageM": 333.0, "kind": "pipe", "id": "p333"},
|
||||
{"chainageM": 77.0, "kind": "structure", "id": "plus-77"},
|
||||
]
|
||||
return sorted(rows, key=lambda row: row["chainageM"])
|
||||
|
||||
|
||||
def _server_stations(stations: list[dict]) -> list[dict]:
|
||||
"""같은 측점(서버 종단 꼴) — 관 · 구조물 = `structure` 이름 · 추가 측점 = 빈 이름."""
|
||||
names = {"pipe": "파형강관 D800", "structure": "옹벽"}
|
||||
return [
|
||||
{
|
||||
"chainage_m": row["chainageM"],
|
||||
"kind": "irregular" if row["kind"] in names else "regular",
|
||||
"structure": "" if (row["id"] or "").startswith("plus-") else names.get(row["kind"]),
|
||||
}
|
||||
for row in stations
|
||||
]
|
||||
|
||||
|
||||
def _ts(tmp_path: Path, cases: list[dict]) -> list:
|
||||
source = tmp_path / "in459.json"
|
||||
source.write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_459_route_seat.cjs"), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=str(ROOT),
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _base(alignment: dict) -> dict:
|
||||
return {
|
||||
"basePvi": alignment["base_pvi"],
|
||||
"chainage": [s["chainage_m"] for s in alignment["samples"]],
|
||||
"ground": [s["ground_elevation_m"] for s in alignment["samples"]],
|
||||
"policy": alignment["policy"],
|
||||
"onlyExplicitCurves": False,
|
||||
"stations": [],
|
||||
}
|
||||
|
||||
|
||||
def _planline_case(alignment: dict, stations: list[dict], **extra) -> dict:
|
||||
chainage = [float(s) for s in range(0, int(NEW_LEN) + 1)]
|
||||
return {
|
||||
"op": "planline",
|
||||
"base": _base(alignment),
|
||||
"oldXy": OLD_XY,
|
||||
"newXy": NEW_XY,
|
||||
"stations": stations,
|
||||
"chainage": chainage,
|
||||
"ground": [round(_new_ground(s), 6) for s in chainage],
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _seat_py(alignment: dict, carried: list[dict], born: list[float]) -> list[dict]:
|
||||
samples = [
|
||||
{"chainage_m": float(s), "ground_elevation_m": round(_new_ground(s), 6)}
|
||||
for s in range(0, int(NEW_LEN) + 1)
|
||||
]
|
||||
return seat_route_moved(
|
||||
{"samples": samples, "policy": alignment["policy"], "base_pvi": carried}, born
|
||||
)
|
||||
|
||||
|
||||
def _plan_at(nodes: list[dict], policy: dict, at: float) -> float:
|
||||
s = np.array([n["chainage_m"] for n in nodes], dtype=np.float64)
|
||||
z = np.array([n["elevation_m"] for n in nodes], dtype=np.float64)
|
||||
curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(policy))
|
||||
return float(evaluate(s, z, curves, np.array([at]))[0])
|
||||
|
||||
|
||||
def _same_nodes(mine: list[dict], theirs: list[dict]) -> None:
|
||||
assert [n["chainage_m"] for n in mine] == pytest.approx([n["chainage_m"] for n in theirs])
|
||||
for a, b in zip(mine, theirs, strict=True):
|
||||
assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6), a["chainage_m"]
|
||||
|
||||
|
||||
def test_옮긴_시설_측점_TS_파이썬_같음(tmp_path: Path) -> None:
|
||||
stations = _stations()
|
||||
server = route_moved_stations(_server_stations(stations), OLD_XY, NEW_XY)
|
||||
plain, moved = _ts(
|
||||
tmp_path,
|
||||
[
|
||||
{"op": "moved", "stations": stations, "oldXy": OLD_XY, "newXy": NEW_XY},
|
||||
{
|
||||
"op": "moved",
|
||||
"stations": stations,
|
||||
"oldXy": OLD_XY,
|
||||
"newXy": NEW_XY,
|
||||
"moved": ["p120"],
|
||||
},
|
||||
],
|
||||
)
|
||||
assert server == plain == [251.0, 333.0] # 같은 자리 관 120 · 추가 측점 77 은 뺌
|
||||
assert moved == [120.0, 251.0, 333.0] # 사용자가 옮긴 관은 같은 자리여도 세움
|
||||
|
||||
|
||||
def test_초안_planline_은_서버와_같은_꼭짓점(tmp_path: Path) -> None:
|
||||
alignment = _old_alignment()
|
||||
[out] = _ts(tmp_path, [_planline_case(alignment, _stations())])
|
||||
server = _seat_py(alignment, out["carried"], [251.0, 333.0])
|
||||
_same_nodes(out["basePvi"], server)
|
||||
# 옮긴 시설 측점 계획선 = 새 원지반(옛 계획선이 지반 종단) · 같은 자리 관 120 = 옛 높이
|
||||
for at in (251.0, 333.0):
|
||||
assert _plan_at(out["basePvi"], alignment["policy"], at) == pytest.approx(
|
||||
_new_ground(at), abs=1e-4
|
||||
)
|
||||
old = {n["chainage_m"]: n["elevation_m"] for n in out["carried"]}
|
||||
new = {n["chainage_m"]: n["elevation_m"] for n in out["basePvi"]}
|
||||
assert new[120.0] == pytest.approx(old[120.0], abs=1e-9)
|
||||
|
||||
|
||||
def test_노선_초안_중_마커로_옮긴_관도_즉시_세움(tmp_path: Path) -> None:
|
||||
alignment = _old_alignment()
|
||||
case = _planline_case(
|
||||
alignment,
|
||||
_stations(pipe_at=125.0),
|
||||
pipes=[{"id": "p120", "chainage_m": 125.0}],
|
||||
followed={"pipes": [{"id": "p120", "chainage_m": 120.0}]},
|
||||
)
|
||||
[out] = _ts(tmp_path, [case])
|
||||
server = _seat_py(alignment, out["carried"], [125.0, 251.0, 333.0])
|
||||
_same_nodes(out["basePvi"], server)
|
||||
assert _plan_at(out["basePvi"], alignment["policy"], 125.0) == pytest.approx(
|
||||
_old_ground(125.0), abs=1e-4
|
||||
)
|
||||
|
||||
|
||||
def test_구조물_초안은_서버_따라감_위에(tmp_path: Path) -> None:
|
||||
before = [
|
||||
{"structure_id": "a", "chainage_m": 100.0, "x": 100.0, "y": 0.0}, # 따라가기만(브라우저)
|
||||
{"structure_id": "b", "chainage_m": 150.0, "x": 150.0, "y": 0.0}, # 안 따라감(옛 판)
|
||||
{"structure_id": "c", "chainage_m": 180.0, "x": 180.0, "y": 0.0}, # 마커로 옮김(좌표 낡음)
|
||||
{
|
||||
"structure_id": "d",
|
||||
"chainage_m": 300.0,
|
||||
"start_m": 295.0,
|
||||
"end_m": 305.0,
|
||||
"x": 300.0,
|
||||
"y": 0.0,
|
||||
},
|
||||
]
|
||||
after = [
|
||||
{"structure_id": "a", "chainage_m": 110.0, "x": 100.0, "y": 0.0},
|
||||
{"structure_id": "b", "chainage_m": 160.0, "x": 150.0, "y": 0.0},
|
||||
{"structure_id": "c", "chainage_m": 190.0, "x": 180.0, "y": 0.0},
|
||||
{
|
||||
"structure_id": "d",
|
||||
"chainage_m": 310.0,
|
||||
"start_m": 305.0,
|
||||
"end_m": 315.0,
|
||||
"x": 300.0,
|
||||
"y": 0.0,
|
||||
},
|
||||
]
|
||||
draft = [
|
||||
{"structure_id": "a", "chainage_m": 110.0, "x": 100.0, "y": 0.0, "memo": "옵션"},
|
||||
{"structure_id": "b", "chainage_m": 150.0, "x": 150.0, "y": 0.0},
|
||||
{"structure_id": "c", "chainage_m": 197.0, "x": 180.0, "y": 0.0},
|
||||
# 구간 끝만 늘림(따라간 뒤) — 사용자 고침 = 초안 그대로
|
||||
{
|
||||
"structure_id": "d",
|
||||
"chainage_m": 310.0,
|
||||
"start_m": 305.0,
|
||||
"end_m": 320.0,
|
||||
"x": 300.0,
|
||||
"y": 0.0,
|
||||
},
|
||||
{"structure_id": "e", "chainage_m": 50.0}, # 새로 놓음
|
||||
]
|
||||
[out] = _ts(tmp_path, [{"op": "rebase", "draft": draft, "before": before, "after": after}])
|
||||
by = {row["structure_id"]: row for row in out}
|
||||
assert by["a"]["chainage_m"] == 110.0 and by["a"]["memo"] == "옵션"
|
||||
assert by["b"]["chainage_m"] == 160.0 # 옛 판 = 서버 따라감
|
||||
assert by["c"]["chainage_m"] == 197.0 # 사용자가 옮긴 자리 지킴
|
||||
assert (by["d"]["start_m"], by["d"]["end_m"]) == (305.0, 320.0)
|
||||
assert by["e"] == {"structure_id": "e", "chainage_m": 50.0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chainages", [[30.0, 120.0, 250.0, 390.0], [199.0, 200.0, 201.0]])
|
||||
def test_추가_측점_따라감_TS_파이썬_같음(tmp_path: Path, chainages: list[float]) -> None:
|
||||
save_plus_stations(tmp_path, chainages, OLD_XY)
|
||||
follow_plus_stations(tmp_path, NEW_XY)
|
||||
[mine] = _ts(tmp_path, [{"op": "plus", "list": chainages, "from": OLD_XY, "to": NEW_XY}])
|
||||
assert mine == plus_chainages(tmp_path)
|
||||
|
||||
|
||||
def test_관_따라감_선은_사용_범위_자른_points_좌표_없는_옛_관도(monkeypatch) -> None:
|
||||
"""B04 노선(범위 안 자름 · 0 m 부터) 대신 `points`(50 m 부터) · 좌표 없는 옛 관 = 옛
|
||||
노선 자리로 채운 뒤."""
|
||||
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed
|
||||
import common_util.common_util_drainage_pipes as drainage
|
||||
from B05_Profile import B05_Profile_Route_Save as route_save
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import vertices_of
|
||||
|
||||
async def prepared(_project_id):
|
||||
return {"vertices": vertices_of([(0.0, 0.0), (400.0, 0.0)]), "streams": []}
|
||||
|
||||
saved: list = []
|
||||
monkeypatch.setattr(watershed, "_prepare", prepared)
|
||||
monkeypatch.setattr(drainage, "save_pipe_points", lambda *args: saved.append(args[2]))
|
||||
old = [
|
||||
PipePoint(chainage_m=100.0, x=150.0, y=0.0),
|
||||
PipePoint(chainage_m=200.0), # 좌표 없는 옛 관
|
||||
]
|
||||
points = [{"x": 40.0, "y": 0.0}, {"x": 350.0, "y": 0.0}] # 시점 10 m 당김
|
||||
old_xy = [[50.0, 0.0], [350.0, 0.0]]
|
||||
pairs, _ = asyncio.run(route_save._place_pipes("p", "s", old, old_xy, points))
|
||||
assert [pipe.chainage_m for _, pipe in pairs] == [110.0, 210.0]
|
||||
assert [pipe.chainage_m for pipe in saved[0]] == [110.0, 210.0]
|
||||
Reference in New Issue
Block a user