Merge remote-tracking branch 'origin/dev' into sub_laptop_4
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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 취소)";
|
||||
|
||||
@@ -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 미달"
|
||||
|
||||
@@ -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,37 @@ 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 => {
|
||||
// [쉬프트] 붉은 강조는 그래프를 다시 그릴 때만 지워짐 — 다른 도구로 넘어가면 바로 뗌(271-1)
|
||||
if (mode !== "shift") lastGeom?.chart.querySelector(".b05-profile-runmark")?.remove();
|
||||
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 +181,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 +200,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 +372,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: {
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { AlignmentCurve, AlignmentPolicy } from "./B05_Profile_UI_Profile_A
|
||||
export const DEFAULT_CURVE_LENGTH_M = 20;
|
||||
/** 초기값 직선 문턱 R(m) — config `initial_straight_radius_m` 짝. */
|
||||
export const INITIAL_STRAIGHT_RADIUS_M = 500;
|
||||
/** 법정 미달 판정 오차(m) — R = L/Δ 를 반올림 높이로 되셈한 L 19.99998 거짓 미달 막음(271-1) · 파이썬 짝. */
|
||||
export const SHORTFALL_TOLERANCE_M = 1e-3;
|
||||
|
||||
/** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것 — 파이썬 `flat_bends` · 시종점 false. */
|
||||
export function flatBends(
|
||||
@@ -72,7 +74,10 @@ export function verticalCurveShortfalls(
|
||||
if (minR <= 0 && minL <= 0) return [];
|
||||
return curves
|
||||
.filter((curve) => !curve.omitted && !curve.skip_allowed)
|
||||
.filter((curve) => curve.r_m < minR - 1e-6 || curve.l_m < minL - 1e-6)
|
||||
.filter(
|
||||
(curve) =>
|
||||
curve.r_m < minR - SHORTFALL_TOLERANCE_M || curve.l_m < minL - SHORTFALL_TOLERANCE_M,
|
||||
)
|
||||
.map(
|
||||
(curve) =>
|
||||
`${curve.chainage_m.toFixed(1)}m: 종단곡선 R ${curve.r_m.toFixed(0)}m · L ${curve.l_m.toFixed(1)}m — ` +
|
||||
|
||||
@@ -109,6 +109,24 @@ export function hasStraightRuns(alignment: ProfileAlignment): boolean {
|
||||
return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index));
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 범위가 이미 한 직선이면 그 범위를 부분 구간으로 돌려준다(271-1) — [직선화] ▲▼ 로 꺾으면
|
||||
* 양 끝에 기본 L 곡선. 사이 꼭짓점이 없거나 하나라도 꺾였으면 null(보통 직선화).
|
||||
*/
|
||||
export function straightSubRun(
|
||||
alignment: ProfileAlignment,
|
||||
fromChainageM: number,
|
||||
toChainageM: number,
|
||||
): StraightRun | null {
|
||||
const fromM = Math.min(fromChainageM, toChainageM);
|
||||
const toM = Math.max(fromChainageM, toChainageM);
|
||||
const inside = alignment.pvi
|
||||
.map((node, index) => ({ at: node.chainage_m, index }))
|
||||
.filter(({ at }) => at > fromM + SAME_STATION_M && at < toM - SAME_STATION_M);
|
||||
if (!inside.length || !inside.every(({ index }) => straightAt(alignment.pvi, index))) return null;
|
||||
return { fromM, toM, nodes: [fromM, ...inside.map(({ at }) => at), toM] };
|
||||
}
|
||||
|
||||
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
|
||||
function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] {
|
||||
return pviChainages(alignment).filter(
|
||||
@@ -131,17 +149,33 @@ function withOffsets(
|
||||
return { station_offsets: stationOffsets, curve_radii: curveRadii };
|
||||
}
|
||||
|
||||
/**
|
||||
* 범위 끝 R 이 저장 자동 R 과 같으면 지운다 — 끝 곡선 = 기본 L(271-1 · ▲▼ 433-13 과 같은 규칙) ·
|
||||
* 사용자 R 은 둠. `radii` 를 고쳐 쓴다.
|
||||
*/
|
||||
export function dropAutoEndRadii(
|
||||
radii: Record<string, number>,
|
||||
ends: number[],
|
||||
autoRadii: Readonly<Record<string, number>>,
|
||||
): void {
|
||||
ends.map(chainageKey).forEach((key) => {
|
||||
if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다.
|
||||
*
|
||||
* 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의
|
||||
* 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다).
|
||||
* 끝 R 이 저장 자동 R(`autoRadii`)이면 지워 기본 L(271-1).
|
||||
*/
|
||||
export function straightenBetween(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
fromChainageM: number,
|
||||
toChainageM: number,
|
||||
autoRadii: Readonly<Record<string, number>> = {},
|
||||
): AlignmentEdits {
|
||||
const fromM = Math.min(fromChainageM, toChainageM);
|
||||
const toM = Math.max(fromChainageM, toChainageM);
|
||||
@@ -156,11 +190,13 @@ export function straightenBetween(
|
||||
const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span;
|
||||
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
|
||||
});
|
||||
return withOffsets(
|
||||
const next = withOffsets(
|
||||
edits,
|
||||
[...updates, ...pinEnds(base, current, [fromM, toM])],
|
||||
inner.map(chainageKey),
|
||||
);
|
||||
dropAutoEndRadii(next.curve_radii, [fromM, toM], autoRadii);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,6 +281,8 @@ export function tiltStraightRun(
|
||||
if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ);
|
||||
else place(chainage, pivot, pivotZ, run.toM, endZ);
|
||||
});
|
||||
// 부분 구간(271-1) 끝이 꼭짓점 아닌 측점이면 지금 높이로 세움 — 끝이 따라 움직이지 않게
|
||||
updates.push(...pinEnds(base, current, [run.fromM, run.toM]));
|
||||
|
||||
const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12);
|
||||
const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12);
|
||||
|
||||
@@ -9,6 +9,7 @@ TS 짝 = `common_util/common_util_structure_attach.ts` `attachShapes`(화면 =
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -28,6 +29,7 @@ from common_util.common_util_structure_pavement import (
|
||||
pavement_specs_from,
|
||||
)
|
||||
from common_util.common_util_structure_station import (
|
||||
PIPE_MATCH_M,
|
||||
SAME_STATION_M,
|
||||
blank_keys,
|
||||
plan_stations,
|
||||
@@ -78,12 +80,54 @@ def _attach_blanks(sections: list[dict[str, Any]], pipes: list[Any], structures:
|
||||
section["blank"] = labels
|
||||
|
||||
|
||||
def attach_shapes(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None:
|
||||
"""측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침)."""
|
||||
def _finite(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and math.isfinite(value)
|
||||
|
||||
|
||||
def _outside_route(pipe: Any, sections: list[dict[str, Any]]) -> bool:
|
||||
"""시종점 밖으로 밀린 관인가(458 · TS `outsideRoute` 짝) — 시종점 측점 자리인데 좌표가 노선 진행
|
||||
반대쪽으로 `PIPE_MATCH_M` 넘게 바깥(옆 거리는 안 봄 · 좌표 없으면 아님)."""
|
||||
placed = sorted(
|
||||
(
|
||||
s
|
||||
for s in sections
|
||||
if all(_finite(s.get(k)) for k in ("chainage_m", "center_x", "center_y"))
|
||||
),
|
||||
key=lambda s: s["chainage_m"],
|
||||
)
|
||||
x, y = getattr(pipe, "x", None), getattr(pipe, "y", None)
|
||||
if len(placed) < 2 or not (_finite(x) and _finite(y)):
|
||||
return False
|
||||
for end, nxt in ((placed[0], placed[1]), (placed[-1], placed[-2])):
|
||||
if abs(end["chainage_m"] - float(pipe.chainage_m)) >= PIPE_MATCH_M:
|
||||
continue
|
||||
dx, dy = nxt["center_x"] - end["center_x"], nxt["center_y"] - end["center_y"]
|
||||
span = math.hypot(dx, dy)
|
||||
if (
|
||||
span > 0
|
||||
and ((x - end["center_x"]) * dx + (y - end["center_y"]) * dy) / span <= -PIPE_MATCH_M
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def attach_shapes(
|
||||
sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]
|
||||
) -> dict[str, list[float]]:
|
||||
"""측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침) ·
|
||||
시종점 밖 관 · 주인 측점 먼 관은 안 얹고 돌려줌(458 · `{"outside", "far"}`)."""
|
||||
for section in sections:
|
||||
for key in (*SHAPE_KEYS, "blank"):
|
||||
section.pop(key, None)
|
||||
attach_culvert_specs(sections, culvert_sets_from(pipes))
|
||||
misses: dict[str, list[float]] = {"outside": [], "far": []}
|
||||
kept = []
|
||||
for pipe in pipes:
|
||||
if _outside_route(pipe, sections):
|
||||
misses["outside"].append(float(pipe.chainage_m))
|
||||
else:
|
||||
kept.append(pipe)
|
||||
pipes = kept
|
||||
attach_culvert_specs(sections, culvert_sets_from(pipes), misses["far"])
|
||||
# 관 세트가 선 측점은 벽을 안 얹음(`attach_wall_specs`) — 차례 = 화면과 같음
|
||||
attach_wall_specs(sections, wall_specs_from(structures))
|
||||
attach_pavement_specs(
|
||||
@@ -92,6 +136,9 @@ def attach_shapes(sections: list[dict[str, Any]], pipes: list[Any], structures:
|
||||
)
|
||||
attach_underdrain_specs(sections, underdrains_from(structures))
|
||||
_attach_blanks(sections, pipes, structures)
|
||||
if misses["outside"] or misses["far"]:
|
||||
logger.warning("B06 시설 그림 안 얹음 — 노선 밖 %s · 측점 멂 %s", *misses.values())
|
||||
return misses
|
||||
|
||||
|
||||
def attach_saved_shapes(project_root: Path, sections: list[dict[str, Any]]) -> None:
|
||||
|
||||
@@ -31,7 +31,7 @@ from typing import Any
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from common_util.common_util_drainage_pipes import parse_pipe_points
|
||||
from common_util.common_util_structure_catalog import catalog_row
|
||||
from common_util.common_util_structure_station import EDGE_TOLERANCE_M
|
||||
from common_util.common_util_structure_station import EDGE_TOLERANCE_M, PIPE_MATCH_M
|
||||
from config.config_system import (
|
||||
DRAINAGE_CACHE_DIRNAME,
|
||||
DRAINAGE_EDITS_DIRNAME,
|
||||
@@ -641,19 +641,16 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
|
||||
|
||||
|
||||
def attach_culvert_specs(
|
||||
cross_sections: list[dict[str, Any]], sets: dict[float, dict[str, Any]]
|
||||
cross_sections: list[dict[str, Any]],
|
||||
sets: dict[float, dict[str, Any]],
|
||||
missed: list[float] | None = None,
|
||||
) -> int:
|
||||
"""세트를 측점에 얹는다(TS `attachCulvertSets` 짝). 얹은 개수를 돌려준다."""
|
||||
"""세트를 측점에 얹음(TS `attachCulvertSets` 짝) · 얹은 수 · 주인 먼 세트 = `missed`."""
|
||||
if not sets:
|
||||
return 0
|
||||
attached = 0
|
||||
# ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
|
||||
# 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다
|
||||
# (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 관 440.241 은 **측점 440.0** 위에 선다.
|
||||
# ⇒ 0.02m 로만 보면 그런 관은 **어느 측점에도 안 붙어** 횡단도에 안 서고 길이도 안 실려
|
||||
# B08 이 「연장 없음」으로 막는다(실측: 배수관 넷이 그렇게 금액에서 빠져 있었다).
|
||||
# ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다. 하나만 고르므로
|
||||
# 두 번 세지 않고, 스냅 폭이 바뀌어도 따라간다.
|
||||
# 주인 측점 = 가장 가까운 측점 하나(`PIPE_MATCH_M` 안 · 정수 m 측점 위 소수 관도) ·
|
||||
# 더 멀면 그 시설 측점이 없음(노선 바뀜) — 연동 이웃에도 안 얹음.
|
||||
owner: dict[float, float] = {}
|
||||
for pipe_chainage in sets:
|
||||
nearest = None
|
||||
@@ -663,8 +660,10 @@ def attach_culvert_specs(
|
||||
continue
|
||||
if nearest is None or abs(value - pipe_chainage) < abs(nearest - pipe_chainage):
|
||||
nearest = value
|
||||
if nearest is not None:
|
||||
if nearest is not None and abs(nearest - pipe_chainage) < PIPE_MATCH_M:
|
||||
owner[pipe_chainage] = nearest
|
||||
elif missed is not None:
|
||||
missed.append(pipe_chainage)
|
||||
for section in cross_sections:
|
||||
chainage = _number(section.get("chainage_m"), None)
|
||||
if chainage is None:
|
||||
@@ -674,6 +673,8 @@ def attach_culvert_specs(
|
||||
# 같은 키끼리는 소유 측점 것 → 흙막이 아닌 자기 시설 → 가까운 것 → 관 지점 차례.
|
||||
best: dict[str, tuple[tuple[Any, ...], dict[str, Any]]] = {}
|
||||
for order, (pipe_chainage, spec) in enumerate(sets.items()):
|
||||
if pipe_chainage not in owner:
|
||||
continue
|
||||
# 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다(`_SPAN_LINKED_TYPES`).
|
||||
reach = _CHAINAGE_TOLERANCE_M
|
||||
if spec.get("type") in _SPAN_LINKED_TYPES:
|
||||
@@ -690,9 +691,8 @@ def attach_culvert_specs(
|
||||
for key, value, is_guard in entries:
|
||||
rank = (not owned, is_guard, abs(chainage - pipe_chainage), order)
|
||||
if key not in best or rank < best[key][0]:
|
||||
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
|
||||
# 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려
|
||||
# **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리).
|
||||
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹음 — 없으면 소비처가 소유 측점을
|
||||
# 못 가려 **같은 시설을 여러 측점에서 셈**(폭 절반 연동 · 관 9개에 길이 10곳).
|
||||
best[key] = (rank, {**value, "chainage_m": pipe_chainage})
|
||||
for key, (_, value) in best.items():
|
||||
section[key] = value
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { readPlusDraft } from "../B05_Profile/B05_Profile_Api_PlusStations";
|
||||
import { registryDefaults } from "@util/common_util_culvert_sets";
|
||||
import { attachShapes } from "@util/common_util_structure_attach";
|
||||
import { attachShapes, type AttachMisses } from "@util/common_util_structure_attach";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
planStations,
|
||||
@@ -100,23 +100,56 @@ function statusOf(
|
||||
|
||||
/** 초안으로 그림 자료를 얹은 상세 — 초안이 없어지면 저장분으로 한 번 다시 얹음. */
|
||||
const drafted = new WeakSet<SectionDetailResponse>();
|
||||
/** 프로젝트마다 마지막으로 알린 안 얹은 시설 — 같은 것은 다시 안 알림. */
|
||||
const warned = new Map<string, string>();
|
||||
|
||||
/** 안 얹은 시설 알림(458) — 시종점 밖으로 밀림 · 주인 측점이 멂(바뀐 때만 한 번). */
|
||||
function warnMisses(projectId: string, misses: AttachMisses): void {
|
||||
const sign = JSON.stringify(misses);
|
||||
if (warned.get(projectId) === sign) return;
|
||||
warned.set(projectId, sign);
|
||||
const parts = [
|
||||
misses.outside.length ? `노선 밖 ${misses.outside.length} 곳` : "",
|
||||
misses.far.length ? `제 측점 없음 ${misses.far.length} 곳` : "",
|
||||
].filter(Boolean);
|
||||
// 알림 틀은 늦게 불러옴(이 모듈을 쓰는 Node 시험이 화면 모듈 없이 돎)
|
||||
if (parts.length)
|
||||
void import("@ui/ui_template_elements").then(({ showToast }) =>
|
||||
showToast(`시설 그림을 얹지 않음 — ${parts.join(" · ")}`, "warning", 6000),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 측점 그림 자료 = 형상 얹기 한 벌(323-4 · 서버 `attach_shapes` 짝) — 현황 = 초안 ?? 저장분 ·
|
||||
* 저장 전 그림 = 저장 뒤 그림. 초안이 없고 앞서 초안으로 얹은 적도 없으면 서버가 얹은 그대로.
|
||||
* 저장 전 그림 = 저장 뒤 그림. 초안이 없고 앞서 초안으로 얹은 적도 없으면 서버가 얹은 그대로 ·
|
||||
* 측점 갈아끼움(`swapped`)은 늘 다시 얹고 카드 이름도 계획 제목으로(앞 횡단 그림 · 이름을 안 들고 감 · 458).
|
||||
*/
|
||||
export async function withDraftShapes(
|
||||
detail: SectionDetailResponse,
|
||||
projectId: string,
|
||||
swapped = false,
|
||||
): Promise<void> {
|
||||
const pipesDraft = readPendingPipes(projectId);
|
||||
const structuresDraft = readPendingStructures(projectId);
|
||||
const drafts = !!(pipesDraft || structuresDraft);
|
||||
if (!drafts && !drafted.has(detail)) return;
|
||||
if (!drafts && !swapped && !drafted.has(detail)) return;
|
||||
const inputs = await statusOf(projectId, pipesDraft, structuresDraft);
|
||||
if (!inputs) return;
|
||||
attachShapes(detail.cross_sections as unknown as Array<Record<string, unknown>>, ...inputs);
|
||||
if (drafts) drafted.add(detail);
|
||||
const sections = detail.cross_sections as unknown as Array<Record<string, unknown>>;
|
||||
const misses = attachShapes(sections, ...inputs);
|
||||
warnMisses(projectId, misses);
|
||||
if (swapped) {
|
||||
const [all, structures, types] = inputs;
|
||||
// 노선 밖 관은 시종점 카드 이름도 안 가져감
|
||||
const pipes = all.filter((pipe) => !misses.outside.includes(pipe.chainage_m));
|
||||
const planned = planStations(pipes, structures, types, registryDefaults(types));
|
||||
for (const section of [...detail.cross_sections, ...(detail.longitudinal?.stations ?? [])]) {
|
||||
const hit = planned.find((station) => near(station.chainage_m, section.chainage_m));
|
||||
if (hit) section.structure = hit.title;
|
||||
else delete section.structure;
|
||||
}
|
||||
}
|
||||
if (drafts || swapped) drafted.add(detail);
|
||||
else drafted.delete(detail);
|
||||
}
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ export async function swapSectionStations(
|
||||
// 메모리만 — 세션 상세 = 저장 정본만(387-7 · 새로고침 · B06 바로 열기 = 묶음이 다시 갈아끼움 · 365-1)
|
||||
cache.set(key, swap.detail);
|
||||
supersedeDraftStations(current); // 옛 상세에 돌던 맞춤이 새 열쇠 목록을 덮지 않게(415 H1)
|
||||
await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4)
|
||||
await withDraftShapes(swap.detail, projectId, true); // 늘 지금 현황으로 다시 얹음 · 이름 = 계획(323-4 · 458)
|
||||
withStructureAreas(swap.detail);
|
||||
if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음
|
||||
emitSwap(swap.detail, swap.moves); // 세션 값 옮김 · 다시 그림(어느 보기든)
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가
|
||||
* 그 행을 지우고 기본 설계에 [저장] patch 를 얹음(`sync_cross_section_rows` · `compute_default_designs` · 428) ·
|
||||
* patch 가 그 칸을 모두 실음(`crossPatchesFromCache` · 445 H2).
|
||||
* · 격자 아닌 자리 = 앞 횡단의 `grid` 표시 · 초안 사본 표시(`preview`)를 지움 · 카드 이름은 형상 다시 얹기가
|
||||
* 계획 제목으로(458).
|
||||
* · 빠진 측점 = 목록에서 뺌(세션 고침은 열쇠로 남음 · 노선이 바뀐 때만 「노선 밖 고침」 · 부르는 쪽).
|
||||
* · 옛 상세는 그대로 둠 — 새 객체를 돌려줌(쥔 쪽이 갈아끼움).
|
||||
*
|
||||
@@ -214,8 +216,16 @@ export function swapCrossSections(
|
||||
kind === "irregular" &&
|
||||
!!intervalM &&
|
||||
Math.abs(station.chainageM / intervalM - Math.round(station.chainageM / intervalM)) < 1e-6;
|
||||
// 옮긴 측점의 옛 자리에 다른 측점이 섬(격자 자리를 떠난 구간 끝 등) = 새 번호 — 옛 번호를 들고 가면 그 자리 새
|
||||
// 측점 번호(`stationIdOf`)와 겹침(458) · 안 겹치면 옛 번호(MC 이동 · 433-4)
|
||||
const vacated =
|
||||
!!prior &&
|
||||
Math.abs(prior.chainage_m - station.chainageM) >= 0.01 &&
|
||||
stations.some(
|
||||
(other) => other !== station && Math.abs(other.chainageM - prior.chainage_m) < 0.0005,
|
||||
);
|
||||
const place = {
|
||||
station_id: prior?.station_id ?? stationIdOf(station.chainageM),
|
||||
station_id: prior && !vacated ? prior.station_id : stationIdOf(station.chainageM),
|
||||
chainage_m: station.chainageM,
|
||||
label: formatStation(station.chainageM),
|
||||
kind,
|
||||
@@ -238,13 +248,17 @@ export function swapCrossSections(
|
||||
const reseed =
|
||||
(station.kind === "pipe" || station.kind === "structure") &&
|
||||
Math.abs(prior.chainage_m - station.chainageM) > FACILITY_RESEED_M;
|
||||
return {
|
||||
const next: CrossSection = {
|
||||
...prior,
|
||||
...place,
|
||||
uphill_side: uphill,
|
||||
samples,
|
||||
...(reseed ? { design: reseeded(prior.design, uphill) } : {}),
|
||||
};
|
||||
if (!onGrid) delete next.grid; // 격자 자리를 떠난 시설 측점 — 옛 숨은 일반 측점 표시를 안 들고 감(458)
|
||||
// 묶음 측점 = 정본 목록 — 초안 맞춤의 「구조물 없는 사본」 표시를 안 들고 감(들고 가면 다음 맞춤이 그 측점을 버림)
|
||||
delete next.preview;
|
||||
return next;
|
||||
});
|
||||
const removed = detail.cross_sections
|
||||
.map((section, i) => (used.has(section) ? null : oldKeys[i]))
|
||||
|
||||
@@ -39,6 +39,8 @@ const SECTION_KEYS: Record<string, string> = {
|
||||
};
|
||||
/** 폭 절반만큼 옆 측점에도 걸치는 종류 — 물넘이포장만. */
|
||||
const SPAN_LINKED_TYPES = new Set(["ford_pavement"]);
|
||||
/** 주인 측점 거리 상한(m · 458) — `design_selection.PIPE_MATCH_M` · 파이썬 `PIPE_MATCH_M` 짝. */
|
||||
const PIPE_MATCH_M = 0.51;
|
||||
|
||||
export const MIN_PIPE_COVER_M = 0.5;
|
||||
export const CHUTE_THICKNESS_CM = 45.0;
|
||||
@@ -587,18 +589,17 @@ function rankBefore(a: readonly number[], b: readonly number[]): boolean {
|
||||
return at >= 0 && a[at] < b[at];
|
||||
}
|
||||
|
||||
/** 측점 자료에 세트를 얹는다(파이썬 `attach_culvert_sets`). 얹은 개수를 돌려준다. */
|
||||
/** 측점 자료에 세트를 얹는다(파이썬 `attach_culvert_specs`). 얹은 개수를 돌려준다 ·
|
||||
* 주인 측점이 상한 밖인 세트는 안 얹고 그 누가거리를 `missed` 에 담음(458). */
|
||||
export function attachCulvertSets(
|
||||
sections: Array<Record<string, unknown>>,
|
||||
sets: ReadonlyMap<number, CulvertSetSpec>,
|
||||
missed?: number[],
|
||||
): number {
|
||||
if (sets.size === 0) return 0;
|
||||
let attached = 0;
|
||||
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
|
||||
// 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다(횡단 파일명이
|
||||
// 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 관 440.241 은 측점 440.0 위에 선다.
|
||||
// ⇒ 0.02m 로만 보면 그런 관은 어느 측점에도 안 붙어 **그림도 수량도 통째로 빠진다.**
|
||||
// ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다(짝: 파이썬 `attach_culvert_sets`).
|
||||
// 주인 측점 = 가장 가까운 측점 하나(0.51 m 안 · 옛 정수 m 측점 위 소수 관도 붙음 · 짝: 파이썬
|
||||
// `attach_culvert_specs`) — 더 멀면 그 시설 측점이 없는 것(노선 바뀜) · 연동 이웃에도 안 얹고 경고(458).
|
||||
const owner = new Map<number, number>();
|
||||
for (const [pipeChainage] of sets) {
|
||||
let nearest: number | null = null;
|
||||
@@ -609,7 +610,9 @@ export function attachCulvertSets(
|
||||
nearest = value;
|
||||
}
|
||||
}
|
||||
if (nearest !== null) owner.set(pipeChainage, nearest);
|
||||
if (nearest !== null && Math.abs(nearest - pipeChainage) < PIPE_MATCH_M)
|
||||
owner.set(pipeChainage, nearest);
|
||||
else missed?.push(pipeChainage);
|
||||
}
|
||||
for (const section of sections) {
|
||||
const chainage = num(section.chainage_m, null);
|
||||
@@ -621,6 +624,7 @@ export function attachCulvertSets(
|
||||
let order = 0;
|
||||
for (const [pipeChainage, spec] of sets) {
|
||||
order += 1;
|
||||
if (!owner.has(pipeChainage)) continue;
|
||||
// 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다.
|
||||
let reach = CHAINAGE_TOLERANCE_M;
|
||||
if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2;
|
||||
|
||||
@@ -93,16 +93,59 @@ function attachBlanks(
|
||||
}
|
||||
}
|
||||
|
||||
/** 측점마다 그림 자료를 걷고 현황으로 다시 얹음(파이썬 `attach_shapes` 짝 · 제자리 고침). */
|
||||
/** 안 얹은 관 · 시설 누가거리(458) — 시종점 밖으로 밀림 · 주인 측점이 0.51 m 넘게 멂. */
|
||||
export interface AttachMisses {
|
||||
outside: number[];
|
||||
far: number[];
|
||||
}
|
||||
|
||||
/** 주인 측점 거리 상한(m) — `design_selection.PIPE_MATCH_M` · 파이썬 `PIPE_MATCH_M` 짝. */
|
||||
const PIPE_MATCH_M = 0.51;
|
||||
|
||||
/** 시종점 밖으로 밀린 관인가(458) — 시종점 측점 자리인데 좌표가 노선 진행 반대쪽으로 0.51 m 넘게 바깥
|
||||
* (따라감이 누가거리를 0 · 끝에 붙여 둠 · 옆 거리는 안 봄 · 좌표 없으면 아님 · 파이썬 `_outside_route` 짝). */
|
||||
function outsideRoute(
|
||||
pipe: { chainage_m: number; x?: number | null; y?: number | null },
|
||||
sections: ReadonlyArray<Record<string, unknown>>,
|
||||
): boolean {
|
||||
const placed = sections
|
||||
.filter((s) => [s.chainage_m, s.center_x, s.center_y].every(Number.isFinite))
|
||||
.sort((a, b) => Number(a.chainage_m) - Number(b.chainage_m));
|
||||
if (placed.length < 2 || !Number.isFinite(pipe.x) || !Number.isFinite(pipe.y)) return false;
|
||||
const ends: Array<[Record<string, unknown>, Record<string, unknown>]> = [
|
||||
[placed[0], placed[1]],
|
||||
[placed[placed.length - 1], placed[placed.length - 2]],
|
||||
];
|
||||
return ends.some(([end, next]) => {
|
||||
if (Math.abs(Number(end.chainage_m) - pipe.chainage_m) >= PIPE_MATCH_M) return false;
|
||||
const [ex, ey] = [Number(end.center_x), Number(end.center_y)];
|
||||
const [dx, dy] = [Number(next.center_x) - ex, Number(next.center_y) - ey];
|
||||
const span = Math.hypot(dx, dy);
|
||||
return span > 0 && ((pipe.x! - ex) * dx + (pipe.y! - ey) * dy) / span <= -PIPE_MATCH_M;
|
||||
});
|
||||
}
|
||||
|
||||
/** 측점마다 그림 자료를 걷고 현황으로 다시 얹음(파이썬 `attach_shapes` 짝 · 제자리 고침) ·
|
||||
* 시종점 밖 관 · 주인 측점 먼 관은 안 얹고 돌려줌(458 · 경고는 부르는 쪽). */
|
||||
export function attachShapes(
|
||||
sections: Array<Record<string, unknown>>,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
allPipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
types: ReadonlyArray<AttachType>,
|
||||
): void {
|
||||
): AttachMisses {
|
||||
for (const section of sections) for (const key of [...SHAPE_KEYS, "blank"]) delete section[key];
|
||||
const misses: AttachMisses = { outside: [], far: [] };
|
||||
const pipes = allPipes.filter((pipe) => {
|
||||
const out = outsideRoute(pipe as Parameters<typeof outsideRoute>[0], sections);
|
||||
if (out) misses.outside.push(pipe.chainage_m);
|
||||
return !out;
|
||||
});
|
||||
const registry = registryDefaults(types);
|
||||
attachCulvertSets(sections, buildCulvertSets(pipes as readonly CulvertPipePoint[], registry));
|
||||
attachCulvertSets(
|
||||
sections,
|
||||
buildCulvertSets(pipes as readonly CulvertPipePoint[], registry),
|
||||
misses.far,
|
||||
);
|
||||
const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape);
|
||||
// 관 세트가 선 측점은 벽을 안 얹음(`attachWallSpecs`) — 차례 = 서버와 같음
|
||||
attachWallSpecs(
|
||||
@@ -121,4 +164,5 @@ export function attachShapes(
|
||||
);
|
||||
attachUnderdrainSpecs(sections, underdrainSpecsFrom(structures as UnderdrainInput[]));
|
||||
attachBlanks(sections, pipes, structures, types);
|
||||
return misses;
|
||||
}
|
||||
|
||||
@@ -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 업데이트] — 카드 단추 넷 · 쉬프트 · 소단 · 암선 · 조정창 · 구조물 폼 · 종단 관 추가 · 끌기 · 지움 · 평면 패널 교점 끌기 · 되돌리기 · [측점 만들기] · 넓힘 · [전체 반영] · ▲▼ · 직선화 · 자유지정 · [저장] 두 길 요청 줄 · [확정] · [초기화] · 반대 방향 · 예외(빈 관 목록 · 같은 자리 관 둘 · 저장 안 하고 오가기 · 계산 중 · 저장 중 전환).
|
||||
@@ -11,6 +11,7 @@ const ROOT = path.join(__dirname, "..", "..");
|
||||
const DATA = { pipes: [], structures: [], types: [] };
|
||||
const STUBS = {
|
||||
"@ui/cad_host/cad_host": { createCadHost: () => ({}) },
|
||||
"@ui/ui_template_elements": { showToast: () => undefined },
|
||||
"../A00_Common/b_page_state": { readState: () => null, writeState: () => undefined },
|
||||
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 },
|
||||
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
|
||||
|
||||
@@ -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));
|
||||
@@ -76,6 +76,7 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
|
||||
const hit = sections.find((s) => Math.abs(s.chainage_m - pipe.chainage_m) < 0.005);
|
||||
if (hit) hit.culvert = true;
|
||||
}
|
||||
return { outside: [], far: [] };
|
||||
},
|
||||
},
|
||||
// 측점 열쇠 세우기(344-3) — 이 시험 몫 아님
|
||||
|
||||
@@ -142,6 +142,7 @@ function loadTs(target) {
|
||||
const full = path.resolve(target);
|
||||
if (STUBS.has(full)) return STUBS.get(full);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
if (full.endsWith(".css")) return {}; // 화면 꼴(vite 몫)
|
||||
if (full.endsWith(".json")) {
|
||||
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
cache.set(full, data);
|
||||
|
||||
@@ -41,9 +41,9 @@ const hash = load(
|
||||
);
|
||||
catalog.useCatalogDefinition({ types: input.types, ...input.definition });
|
||||
|
||||
const out = { cases: {} };
|
||||
const out = { cases: {}, misses: {} };
|
||||
for (const [name, { sections, pipes, structures }] of Object.entries(input.cases)) {
|
||||
attach.attachShapes(sections, pipes, structures, input.types);
|
||||
out.misses[name] = attach.attachShapes(sections, pipes, structures, input.types);
|
||||
out.cases[name] = sections;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,195 @@
|
||||
/* PLAN 458 노선 바뀜 — 진짜 모듈(따라감 · 측점 · 갈아끼움 · 형상 얹기)을 그 자리에서 트랜스파일해 Node 로 돌림.
|
||||
* 저장 노선 (0,0)→(100,0)→(200,60) 위 관 · 세월교 · 옹벽 → 시점 당김 / 밀어냄 → 갈아끼움 + 지금 현황으로 다시 얹기.
|
||||
* 결과 = 표준출력 JSON. 짝 = `test_458_swap_attach.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
|
||||
const cache = new Map();
|
||||
function resolveTs(base) {
|
||||
for (const c of [`${base}.ts`, base, path.join(base, "index.ts")])
|
||||
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
||||
throw new Error(`모름: ${base}`);
|
||||
}
|
||||
function load(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 need = (spec0) => {
|
||||
if (/common_util_structure_catalog(\.js)?$/.test(spec0))
|
||||
return require(path.join(__dirname, "helper_322_catalog_module.cjs"));
|
||||
const spec = spec0.replace(/\.js$/, "");
|
||||
if (spec.endsWith(".json"))
|
||||
return JSON.parse(fs.readFileSync(path.join(path.dirname(full), spec), "utf8"));
|
||||
if (spec.startsWith("@util/"))
|
||||
return load(resolveTs(path.join(ROOT, "common_util", spec.slice(6))));
|
||||
if (spec.startsWith(".")) return load(resolveTs(path.join(path.dirname(full), spec)));
|
||||
return require(spec);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, need);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const P = (...parts) => path.join(ROOT, ...parts);
|
||||
const follow = load(P("common_util", "common_util_route_follow.ts"));
|
||||
const SS = load(P("B05_Profile", "logic", "B05_Profile_Sections_Stations.ts"));
|
||||
const SW = load(P("B06_Section", "logic", "B06_Section_Station_Swap.ts"));
|
||||
const AT = load(P("common_util", "common_util_structure_attach.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 INTERVAL = 20;
|
||||
const xy = (L) => L.map(([x, y]) => ({ x, y }));
|
||||
function at(L, ch) {
|
||||
let walked = 0;
|
||||
for (let i = 1; i < L.length; i++) {
|
||||
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 kindOf = (s) =>
|
||||
s.kind === "end"
|
||||
? s.key.endsWith("EP")
|
||||
? "ep"
|
||||
: "bp"
|
||||
: s.kind === "grid"
|
||||
? "regular"
|
||||
: s.kind === "curve"
|
||||
? "curve"
|
||||
: "irregular";
|
||||
/** 저장 상세 — 서버처럼 격자 자리 시설 측점에 `grid` · 설계에 열쇠 메모. */
|
||||
const sectionsFrom = (stations) =>
|
||||
stations.map((s) => ({
|
||||
station_id: SW.stationIdOf(s.chainageM),
|
||||
chainage_m: s.chainageM,
|
||||
label: "",
|
||||
kind: kindOf(s),
|
||||
...(kindOf(s) === "irregular" && s.chainageM % INTERVAL === 0 ? { grid: true } : {}),
|
||||
center_x: s.x,
|
||||
center_y: s.y,
|
||||
center_z: 100,
|
||||
frame: { left_xy: s.left },
|
||||
samples: [{ offset_m: 0, elevation_m: 100, valid: true }],
|
||||
uphill_side: "left",
|
||||
design: { memo: s.key, design_elevation_m: 101 },
|
||||
}));
|
||||
const ground = (s) => ({
|
||||
...s,
|
||||
ground: { centerZ: 100, left: [{ offsetM: 5, z: 101 }], right: [{ offsetM: 5, z: 99 }] },
|
||||
});
|
||||
const KEYS = ["culvert", "ford", "box", "ford_pavement"];
|
||||
const view = (sections) =>
|
||||
sections.map((s) => ({
|
||||
chainage_m: s.chainage_m,
|
||||
kind: s.kind,
|
||||
grid: !!s.grid,
|
||||
shapes: Object.fromEntries(KEYS.filter((k) => s[k]).map((k) => [k, s[k].chainage_m])),
|
||||
}));
|
||||
|
||||
function run(L1, L2, pipes, structures) {
|
||||
const S1 = SS.buildStations(L1, [], pipes, structures, { types, intervalM: INTERVAL });
|
||||
const saved = { longitudinal: { stations: [], length_m: 0 }, cross_sections: sectionsFrom(S1) };
|
||||
AT.attachShapes(saved.cross_sections, pipes, structures, types);
|
||||
saved.cross_sections[0].preview = true; // 초안 맞춤의 구조물 없는 사본 표시(갈아끼움이 안 들고 감)
|
||||
const pipesF = follow.projectPipePoints(pipes, xy(L2));
|
||||
const structuresF = follow
|
||||
.followStructures(structures, xy(L2), undefined, INTERVAL)
|
||||
.map((m) => m.item);
|
||||
const S2 = SS.buildStations(L2, [], pipesF, structuresF, { types, intervalM: INTERVAL }).map(
|
||||
ground,
|
||||
);
|
||||
const swap = SW.swapCrossSections(
|
||||
saved,
|
||||
S2,
|
||||
S1.map((s) => s.key),
|
||||
INTERVAL,
|
||||
);
|
||||
const now = swap.detail.cross_sections.map((s) => ({ ...s }));
|
||||
const misses = AT.attachShapes(now, pipesF, structuresF, types);
|
||||
// 가게 초안이 없는 채(저장분 현황) 얹기 — 상한이 옛 자리 관을 이웃 측점에 안 붙임
|
||||
const stale = swap.detail.cross_sections.map((s) => ({ ...s }));
|
||||
const staleMisses = AT.attachShapes(stale, pipes, structures, types);
|
||||
return {
|
||||
ids: swap.detail.cross_sections.map((s) => [s.chainage_m, s.station_id]),
|
||||
preview: swap.detail.cross_sections.filter((s) => s.preview).length,
|
||||
pipes: pipesF.map((p) => [p.id, p.chainage_m]),
|
||||
now: view(now),
|
||||
misses,
|
||||
staleMisses,
|
||||
stale: view(stale),
|
||||
};
|
||||
}
|
||||
|
||||
const L1 = [
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[200, 60],
|
||||
];
|
||||
const pipe = (id, facility, ch, options) => {
|
||||
const [x, y] = at(L1, ch);
|
||||
return { id, facility, chainage_m: ch, x, y, options };
|
||||
};
|
||||
const pipes = [
|
||||
pipe("p1", "pipe", 40, { pipe_diameter_mm: "800" }), // 격자 No.2 위
|
||||
pipe("p2", "ford_bridge", 150, { ford_width_m: 10 }),
|
||||
];
|
||||
const [wx, wy] = at(L1, 70);
|
||||
const wall = {
|
||||
structure_id: "w1",
|
||||
type_id: "retaining_wall",
|
||||
chainage_m: 70,
|
||||
start_m: 60,
|
||||
end_m: 80,
|
||||
x: wx,
|
||||
y: wy,
|
||||
options: { height_m: 2, side: "left" },
|
||||
};
|
||||
|
||||
const out = {
|
||||
// A — 시점 7 m 당김: 관 40 → 47(격자를 떠남) · 세월교 150 → 157
|
||||
shift: run(
|
||||
L1,
|
||||
[
|
||||
[-7, 0],
|
||||
[100, 0],
|
||||
[200, 60],
|
||||
],
|
||||
pipes,
|
||||
[wall],
|
||||
),
|
||||
// D — 시점을 50 m 로 밀어냄: 관 40 = 새 시점 10 m 바깥 → 누가거리 0
|
||||
outside: run(
|
||||
L1,
|
||||
[
|
||||
[50, 0],
|
||||
[100, 0],
|
||||
[200, 60],
|
||||
],
|
||||
pipes,
|
||||
[wall],
|
||||
),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -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
|
||||
@@ -56,12 +56,13 @@ POINTS = [
|
||||
]
|
||||
CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 85.0, 90.0, 95.0, 100.0, 105.0]
|
||||
|
||||
# 같은 키 — 옆 측점 연동(88.5 · 폭 10 · 주인 88)이 90 m 에 더 가깝지만 소유(92.4 · 주인 90)가 먼저
|
||||
# 같은 키 — 옆 측점 연동(89.8 · 폭 10 · 주인 89.7)이 90 m 에 더 가깝지만 소유(90.4 · 주인 90)가 먼저
|
||||
# (주인 측점 = 0.51 m 안 · 458)
|
||||
SAME_KEY_POINTS = [
|
||||
{"chainage_m": 88.5, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}},
|
||||
{"chainage_m": 92.4, "facility": "ford_pavement", "options": {"ford_width_m": 0.1}},
|
||||
{"chainage_m": 89.8, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}},
|
||||
{"chainage_m": 90.4, "facility": "ford_pavement", "options": {"ford_width_m": 0.1}},
|
||||
]
|
||||
SAME_KEY_CHAINAGES = [85.0, 88.0, 90.0, 95.0]
|
||||
SAME_KEY_CHAINAGES = [85.0, 89.7, 90.0, 95.0]
|
||||
|
||||
|
||||
def _attach(tmp_path: Path, points: list[dict], chainages: list[float]) -> list[dict]:
|
||||
@@ -103,8 +104,8 @@ def test_흙막이는_자기_관에_밀림_없으면_그대로(sections):
|
||||
|
||||
def test_같은_키는_소유_측점_먼저(tmp_path):
|
||||
sections = _attach(tmp_path, SAME_KEY_POINTS, SAME_KEY_CHAINAGES)
|
||||
assert _at(sections, 90.0)["ford_pavement"]["chainage_m"] == 92.4
|
||||
assert _at(sections, 85.0)["ford_pavement"]["chainage_m"] == 88.5
|
||||
assert _at(sections, 90.0)["ford_pavement"]["chainage_m"] == 90.4
|
||||
assert _at(sections, 85.0)["ford_pavement"]["chainage_m"] == 89.8
|
||||
|
||||
|
||||
def test_수량은_주인_측점_한_번(sections):
|
||||
|
||||
@@ -99,9 +99,35 @@ def _sections(until: float) -> list[dict]:
|
||||
|
||||
PIPES, STRUCTURES = _samples()
|
||||
UNTIL = 15.0 + 30.0 * len(catalog()) + 60
|
||||
|
||||
|
||||
def _moved() -> dict:
|
||||
"""458 노선 바뀜 — 노선 x 축 0 ~ 100 · 측점 10 m 마다(좌표 있음).
|
||||
|
||||
관 30.4(주인 30 · 0.4 m) · 44(가장 가까운 측점 4 m = 측점 없음) · 0(좌표 -3 = 시점 밖) ·
|
||||
100(좌표 100.3 · 옆 1.5 = 종점 안) · 물넘이포장 70(폭 24 = 60 · 80 연동) · 55(주인 5 m = 연동도 안 얹음).
|
||||
"""
|
||||
pipe = _defaults("pipe")
|
||||
pave = {**_defaults("ford_pavement"), "ford_width_m": 24}
|
||||
sections = [
|
||||
{"chainage_m": float(at), "center_x": float(at), "center_y": 0.0}
|
||||
for at in range(0, 101, 10)
|
||||
]
|
||||
pipes = [
|
||||
{"id": "a", "chainage_m": 30.4, "x": 30.4, "y": 0.0, "facility": "pipe", "options": pipe},
|
||||
{"id": "b", "chainage_m": 44.0, "x": 44.0, "y": 0.0, "facility": "pipe", "options": pipe},
|
||||
{"id": "c", "chainage_m": 0.0, "x": -3.0, "y": 0.0, "facility": "pipe", "options": pipe},
|
||||
{"id": "d", "chainage_m": 100.0, "x": 100.3, "y": 1.5, "facility": "pipe", "options": pipe},
|
||||
{"id": "e", "chainage_m": 70.0, "facility": "ford_pavement", "options": pave},
|
||||
{"id": "f", "chainage_m": 55.0, "facility": "ford_pavement", "options": pave},
|
||||
]
|
||||
return {"sections": sections, "pipes": pipes, "structures": []}
|
||||
|
||||
|
||||
CASES = {
|
||||
"all": {"sections": _sections(UNTIL), "pipes": PIPES, "structures": STRUCTURES},
|
||||
"empty": {"sections": _sections(30), "pipes": [], "structures": []},
|
||||
"moved": _moved(),
|
||||
}
|
||||
|
||||
|
||||
@@ -122,22 +148,36 @@ def ts() -> dict:
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def _py(case: dict) -> list[dict]:
|
||||
def _py(case: dict) -> tuple[list[dict], dict]:
|
||||
sections = copy.deepcopy(case["sections"])
|
||||
pipes = parse_pipe_points(case["pipes"])
|
||||
structures = [StructureInstance.model_validate(s) for s in case["structures"]]
|
||||
attach_shapes(sections, pipes, structures)
|
||||
return json.loads(json.dumps(sections))
|
||||
misses = attach_shapes(sections, pipes, structures)
|
||||
return json.loads(json.dumps(sections)), misses
|
||||
|
||||
|
||||
def test_mirror_python_equals_ts(ts: dict) -> None:
|
||||
for name, case in CASES.items():
|
||||
py, js = _py(case), ts["cases"][name]
|
||||
(py, misses), js = _py(case), ts["cases"][name]
|
||||
assert misses == ts["misses"][name], name
|
||||
assert len(py) == len(js), name
|
||||
for left, right in zip(py, js):
|
||||
assert left == right, (name, left["chainage_m"])
|
||||
|
||||
|
||||
def test_owner_cap_and_outside(ts: dict) -> None:
|
||||
"""458 — 주인 측점 0.51 m 상한 · 시종점 밖 = 안 얹고 경고 · 연동 이웃 = 폭 절반."""
|
||||
by_at = {s["chainage_m"]: s for s in ts["cases"]["moved"]}
|
||||
assert ts["misses"]["moved"] == {"outside": [0.0], "far": [44.0, 55.0]}
|
||||
assert by_at[30.0]["culvert"]["chainage_m"] == 30.4
|
||||
assert by_at[100.0]["culvert"]["chainage_m"] == 100.0 # 종점 0.3 m 넘음 · 옆 1.5 m = 노선 안
|
||||
assert "culvert" not in by_at[0.0] and "culvert" not in by_at[40.0]
|
||||
assert "culvert" not in by_at[50.0]
|
||||
linked = {at for at, s in by_at.items() if "ford_pavement" in s}
|
||||
assert linked == {60.0, 70.0, 80.0}
|
||||
assert all(by_at[at]["ford_pavement"]["chainage_m"] == 70.0 for at in linked)
|
||||
|
||||
|
||||
def test_every_shape_lands(ts: dict) -> None:
|
||||
keys = {key for section in ts["cases"]["all"] for key in section if key != "chainage_m"}
|
||||
assert {
|
||||
|
||||
@@ -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,82 @@
|
||||
"""458 노선 바뀜 — B06 갈아끼움 + 지금 현황으로 다시 얹기 · 주인 측점 거리 상한 · 시종점 밖.
|
||||
|
||||
못박는 것
|
||||
- 시점 당김(재현 A 꼴): 관 · 세월교 그림 = 제 측점 하나씩(따라간 누가거리) · 격자를 떠난 관 측점 `grid` 지움.
|
||||
- 가게 초안 없는 채(저장분 현황) 얹어도 옛 자리 세월교가 이웃 일반 측점에 안 붙음(상한 0.51 m · 측점 없음).
|
||||
- 시점 밖으로 밀린 관(재현 D 꼴) = 시점 측점에 안 얹고 「노선 밖」.
|
||||
- 카드 번호 안 겹침 — 격자 자리(80 m)를 떠난 옹벽 끝 측점은 새 번호(그 자리 새 일반 측점과 따로) ·
|
||||
초안 사본 표시(`preview`)는 안 들고 감.
|
||||
|
||||
Node 헬퍼 `helper_458_swap_attach.cjs` 가 진짜 모듈(따라감 · 측점 · 갈아끼움 · 형상 얹기)을 부름.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_458_swap_attach.cjs")],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _shaped(sections: list[dict]) -> dict[float, dict]:
|
||||
return {s["chainage_m"]: s["shapes"] for s in sections if s["shapes"]}
|
||||
|
||||
|
||||
def test_시설마다_제_측점_하나(out):
|
||||
shift = out["shift"]
|
||||
assert shift["pipes"] == [["p1", 47], ["p2", 157]]
|
||||
assert _shaped(shift["now"]) == {47: {"culvert": 47}, 157: {"ford": 157}}
|
||||
assert shift["misses"] == {"outside": [], "far": []}
|
||||
|
||||
|
||||
def test_격자_떠난_시설_측점_grid_지움(out):
|
||||
by_at = {s["chainage_m"]: s for s in out["shift"]["now"]}
|
||||
assert by_at[47]["kind"] == "irregular" and by_at[47]["grid"] is False
|
||||
|
||||
|
||||
def test_저장분_현황이어도_이웃에_안_붙음(out):
|
||||
shift = out["shift"]
|
||||
assert shift["staleMisses"]["far"] == [150]
|
||||
assert all("ford" not in shapes for shapes in _shaped(shift["stale"]).values())
|
||||
|
||||
|
||||
def test_시점_밖_관은_안_얹고_노선_밖(out):
|
||||
outside = out["outside"]
|
||||
assert ["p1", 0] in outside["pipes"]
|
||||
assert outside["misses"] == {"outside": [0], "far": []}
|
||||
assert _shaped(outside["now"]) == {100: {"ford": 100}}
|
||||
assert {s["chainage_m"]: s["grid"] for s in outside["now"]}[20] is True # 격자 자리 옹벽 측점
|
||||
|
||||
|
||||
def test_카드_번호_안_겹침_떠난_끝_측점은_새_번호(out):
|
||||
ids = dict(out["shift"]["ids"])
|
||||
assert len(set(ids.values())) == len(ids)
|
||||
assert ids[87] == "station_000000087000" # 옹벽 끝 80 → 87
|
||||
assert ids[80] == "station_000000080000" # 그 자리 새 일반 측점
|
||||
assert ids[47] == "station_000000047000" # 관도 격자 40 을 떠남
|
||||
assert ids[40] == "station_000000040000"
|
||||
assert ids[157] == "station_000000150000" # 옛 자리가 빈 세월교 측점 = 옛 번호 그대로
|
||||
|
||||
|
||||
def test_초안_사본_표시_안_들고_감(out):
|
||||
assert out["shift"]["preview"] == 0 and out["outside"]["preview"] == 0
|
||||
Reference in New Issue
Block a user