feat(구조물표): 322-3 측점 계획 한 벌(TS · 파이썬 짝) — 카드 목록 · B05 종단 · 3D 막대 · B06 종단 띠(모든 종류) · 서버 측점이 같은 목록 · 일반 측점 자리 새 구조물 이름 · 형상(219) · 서버 낡음 = 자리 + 이름 · 옮긴 관 고름 유지 · 배수 패널 되먹임 끊음
- 측점 계획 planStations(TS) ↔ plan_stations(py): 관 · 구조물 + 구조물 표 → 측점 · 역할 · 카드 이름 · 덮는 구조물 id · 빈칸 경고(형상 부품 필수 칸) · 같은 m 하나(이름 = 관 먼저 · 앞선 것) - 서버 resolve_extra_stations = 계획(같은 자리 한 번 · 옛 「마지막 이름」 걷음) · _stale_marks = 자리 + 이름(종류 · 관 공식 · 정의 판 이름) - 카드 목록(Section_Drafts) = 계획 이름 · 일반 측점 자리(regular)에 새로 놓은 구조물 = 그 자리 구조물 없는 사본에 이름 칩 · 관 그림 · 벽 · 포장(219 흠) - B05 종단 · 3D 측점 = 계획(design_structure_status.plannedIrregular) · 배수유역도로 관 자리 되먹임 끊음 - B06 종단 띠 = 계획(관만 → 모든 종류 · 관 구간도 넘김) - 고름: followMovedPipe — 고른 관을 옮기면 고름도 새 자리(B05 폼 · 종단 끌기 · B06 옮김) - 부품 상자 형상 「필수 칸」(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 · 기슭막이 높이) - 시험: test_322_3_plan_mirror(거울 · 규칙 · 서버 공급자 · 낡음) · test_322_3_flow(흐름 일곱 + 고름 따라감) · 272_1 = 같은 자리 한 번 · 318 도움 파일 표 모듈 한 줄 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -188,3 +188,17 @@ export function selectDesign(
|
||||
dispatching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 관을 옮김 — 그 관을 골라 둔 고름이면 새 자리로(322-3 · 관 id = 자리 이름 `pipe-{누가거리}` 라 id 도
|
||||
* 새 자리로 · 옮겨도 고름 유지 · 부재키 그대로). 다른 것을 골랐으면 그대로. */
|
||||
export function followMovedPipe(projectId: string | null, fromM: number, toM: number): void {
|
||||
const picked = readSelection(projectId);
|
||||
if (!picked || picked.chainageM === null || !isNear(picked.chainageM, fromM)) return;
|
||||
const sid = picked.structureId;
|
||||
if (sid !== null && !sid.startsWith("pipe-")) return;
|
||||
selectDesign(
|
||||
projectId,
|
||||
{ chainageM: toM, structureId: sid === null ? null : `pipe-${toM.toFixed(2)}` },
|
||||
{ partKey: picked.partKey },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_structure_status.ts
|
||||
* 구조물 현황 한 벌(322-3) — 저장분 둘(관 `pipe_points.json` · 구조물 `structures.json`) + 초안 둘(세션)
|
||||
* → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) →
|
||||
* 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴).
|
||||
*
|
||||
* 구조물 id = 구조물은 structure_id · 관은 자리 이름 `pipe-{누가거리 2자리}`(파일 모양 그대로 · 저장 안 함).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import type { PlannedStation } from "../../common_util/common_util_structure_station";
|
||||
|
||||
/** 현황 — 종류마다 초안이 앞섬(초안 null = 만진 적 없음 · 빈 목록 = 다 지움). */
|
||||
export function structureStatus<P, S>(
|
||||
saved: readonly [ReadonlyArray<P>, ReadonlyArray<S>],
|
||||
drafts: readonly [ReadonlyArray<P> | null, ReadonlyArray<S> | null],
|
||||
): { pipes: P[]; structures: S[] } {
|
||||
return { pipes: [...(drafts[0] ?? saved[0])], structures: [...(drafts[1] ?? saved[1])] };
|
||||
}
|
||||
|
||||
/** 관 id(현황 · 알약 · 고름이 같은 값) — 자리 이름. */
|
||||
export const pipeId = (chainageM: number): string => `pipe-${chainageM.toFixed(2)}`;
|
||||
|
||||
/**
|
||||
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||
*/
|
||||
export function plannedIrregular(
|
||||
planned: ReadonlyArray<PlannedStation>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
const interval = intervalM > 0 ? intervalM : 20;
|
||||
return planned.map((station) => {
|
||||
const owner = station.owners[0];
|
||||
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||
const number = Math.floor(station.chainage_m / interval);
|
||||
return {
|
||||
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||
station: number,
|
||||
remainder: station.chainage_m - number * interval,
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.title,
|
||||
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { leaveForDashboard } from "../b_missing_data_guard";
|
||||
import { readByKey, stateKey, writeByKey } from "../b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations";
|
||||
import type { PlanPipe } from "../../common_util/common_util_structure_station";
|
||||
import {
|
||||
onStaleStructureTypes,
|
||||
type StructureInstance,
|
||||
@@ -136,7 +137,12 @@ export async function mountCrossView(
|
||||
|
||||
// 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b).
|
||||
let structureMarksSink:
|
||||
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
|
||||
| ((
|
||||
structures: StructureInstance[],
|
||||
types: StructureType[],
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
) => void)
|
||||
| null = null;
|
||||
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
|
||||
let pipeReloadTimer = 0;
|
||||
let wallAdjustTimer = 0;
|
||||
@@ -144,8 +150,8 @@ export async function mountCrossView(
|
||||
projectId,
|
||||
shared,
|
||||
// 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4).
|
||||
onMarks: (structures, types) => {
|
||||
structureMarksSink?.(structures, types);
|
||||
onMarks: (structures, types, pipes) => {
|
||||
structureMarksSink?.(structures, types, pipes);
|
||||
syncBermSpans(structures);
|
||||
},
|
||||
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06).
|
||||
@@ -338,7 +344,8 @@ export async function mountCrossView(
|
||||
shared,
|
||||
);
|
||||
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
|
||||
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
|
||||
structureMarksSink = (structures, types, pipes) =>
|
||||
sectionView.setStructureMarks(structures, types, pipes);
|
||||
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙.
|
||||
|
||||
// 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로
|
||||
|
||||
@@ -25,7 +25,6 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
is_station_planting_type,
|
||||
structure_type_map,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
@@ -41,7 +40,8 @@ from common_util.common_util_route_geometry import (
|
||||
planned_route_initial_path,
|
||||
planned_route_working_path,
|
||||
)
|
||||
from common_util.common_util_structure_catalog import card_title, catalog, catalog_row
|
||||
from common_util.common_util_structure_catalog import card_title, catalog
|
||||
from common_util.common_util_structure_station import plan_stations
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
from config.config_system import (
|
||||
DRAINAGE_CACHE_DIRNAME,
|
||||
@@ -259,52 +259,20 @@ def resolve_extra_stations(
|
||||
|
||||
물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1).
|
||||
|
||||
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
|
||||
되짚기 때문이다(B05_Profile_Structures_Migration).
|
||||
TS 짝 = `common_util_structure_station.ts` `plantedChainages`(화면 측점 한 벌 · 272) ·
|
||||
거울 시험 = `resources/tester/test_272_1_station_list.py`.
|
||||
측점 · 이름 = 측점 계획 한 벌(322-3 · `plan_stations` · TS `planStations` 짝) — 표 횡단도
|
||||
(anchor = 기준 한 곳 · span3 = 시작 · 기준 · 끝) · 카드 이름 · 같은 m(0.005) 는 하나(이름 = 관이
|
||||
먼저 · 아니면 앞선 것). 관 지점 정본 종류는 structures.json 에 없어 겹치지 않는다.
|
||||
거울 시험 = `resources/tester/test_322_3_plan_mirror.py` · `test_272_1_station_list.py`.
|
||||
"""
|
||||
types = structure_type_map()
|
||||
derived: list[tuple[float, str]] = []
|
||||
for pipe in pipes:
|
||||
label = pipe_station_label(pipe, types)
|
||||
if label is None:
|
||||
continue
|
||||
# 표 횡단도(322-1) — span3(독립 기슭막이) = **시작·기준·종료** 세 측점(길수록 횡단도가
|
||||
# 여러 장) · anchor(그 밖 관 시설) = 기준 한 곳(관이 노선을 가로지르는 점형) · none = 없음.
|
||||
station = (catalog_row(pipe.facility) or {}).get("station", "anchor")
|
||||
if station == "none":
|
||||
continue
|
||||
if station == "span3":
|
||||
marks = (pipe.start_m, pipe.chainage_m, pipe.end_m)
|
||||
else:
|
||||
marks = (pipe.chainage_m,)
|
||||
for mark in marks:
|
||||
if mark is not None:
|
||||
derived.append((float(mark), label))
|
||||
# 구조물 정본에서 측점이 필요한 것들. 관 정본이 관리하는 타입(managed_by)은
|
||||
# structures.json에 저장되지 않아 중복되지 않는다.
|
||||
# · 점형(A군 노출형 횡단수로·개거): 기준 측점 한 곳.
|
||||
# · 구간형(D군 기슭막이): **시작·기준·종료** — 길이가 길수록 횡단도가 여러 장
|
||||
# 나와야 한다(2026-08-28 사용자). 격자와 같은 정수 미터면 generate_sections가
|
||||
# 격자로 스냅해 파일이 겹치지 않는다.
|
||||
try:
|
||||
for structure in load_structures(str(project_root))[1]:
|
||||
definition = types.get(structure.type_id)
|
||||
if definition is None or definition.managed_by:
|
||||
continue
|
||||
if not is_station_planting_type(definition):
|
||||
continue
|
||||
if catalog_row(structure.type_id)["station"] == "span3":
|
||||
marks = (structure.start_m, structure.chainage_m, structure.end_m)
|
||||
else:
|
||||
marks = (structure.anchor_m(),)
|
||||
for mark in marks:
|
||||
if mark is not None:
|
||||
derived.append((float(mark), definition.name))
|
||||
structures = load_structures(str(project_root))[1]
|
||||
except Exception:
|
||||
logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)")
|
||||
return tuple(sorted(derived))
|
||||
structures = []
|
||||
return tuple(
|
||||
(float(station["chainage_m"]), str(station["title"]))
|
||||
for station in plan_stations(pipes, structures)
|
||||
)
|
||||
|
||||
|
||||
def _append_design_profiles(
|
||||
|
||||
@@ -152,11 +152,6 @@ def is_station_planting_type(item: StructureType) -> bool:
|
||||
return row is not None and row["station"] != "none"
|
||||
|
||||
|
||||
def station_planting_labels() -> set[str]:
|
||||
"""서버가 심는 측점 라벨 집합 — 구조물로 되옮기면 정본이 이중화된다."""
|
||||
return {item.name for item in load_structure_types() if is_station_planting_type(item)}
|
||||
|
||||
|
||||
#: 지난 판 정의(`판_N.json`) 폴더 — 정본 옆(M02 `M02_SO_Definition.HISTORY` 와 같은 이름)
|
||||
HISTORY_DIRNAME = "구조물정의_이력"
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
|
||||
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
|
||||
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
|
||||
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { followMovedPipe, PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
@@ -584,6 +584,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
if (index < 0) return;
|
||||
const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게)
|
||||
facilityStore.move(pipeEditor.pipes()[index].chainage_m, to);
|
||||
followMovedPipe(projectId, pipeEditor.pipes()[index].chainage_m, to); // 고름 유지(322-3)
|
||||
pipeEditor.moveTo(index, to);
|
||||
},
|
||||
selectBasinByChainage(chainageM) {
|
||||
@@ -637,6 +638,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
const index = pipeEditor
|
||||
.pipes()
|
||||
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M);
|
||||
followMovedPipe(projectId, fromChainageM, toChainageM); // 고름 유지(322-3)
|
||||
if (index >= 0) pipeEditor.moveTo(index, toChainageM);
|
||||
else void analyze();
|
||||
} else {
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { PIPE_DEFAULT_TYPE, pickPipeDiameter } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
@@ -25,25 +24,17 @@ import {
|
||||
writePendingStructures,
|
||||
StructureConflictError,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import {
|
||||
isPipeStation,
|
||||
structureLabel,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
||||
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
||||
import { followedStation } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { followSnapped } from "./B05_Profile_UI_Structures_Snap";
|
||||
import { plantsStation } from "./B05_Profile_UI_Profile_Structures";
|
||||
import { listPick } from "../common_util/common_util_structure_catalog";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
SAME_STATION_M,
|
||||
structureStationMarks,
|
||||
type StationMark,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
import { planStations } from "../common_util/common_util_structure_station";
|
||||
import { plannedIrregular } from "../A00_Common/design/design_structure_status";
|
||||
|
||||
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
||||
export interface PipeProjection {
|
||||
@@ -68,56 +59,12 @@ export interface StructuresBridgeDeps {
|
||||
}
|
||||
|
||||
export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
/** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */
|
||||
/** 비정규 측점(그래프 세로선·3D 라벨) — 측점 계획 한 벌(관 · 측점 심는 구조물 · 322-3). */
|
||||
let irregularStations: IrregularStation[] = [];
|
||||
|
||||
/** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */
|
||||
function stationAt(
|
||||
chainage: number,
|
||||
): Pick<IrregularStation, "station" | "remainder" | "chainage_m"> {
|
||||
const interval = deps.panel().values().stationInterval || 20;
|
||||
const station = Math.floor(chainage / interval);
|
||||
return { station, remainder: chainage - station * interval, chainage_m: chainage };
|
||||
}
|
||||
|
||||
/** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서
|
||||
* 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */
|
||||
const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] =>
|
||||
marks
|
||||
.filter((mark) => mark.role !== "anchor")
|
||||
.map((mark) => ({
|
||||
id: `${id}:${mark.role}`,
|
||||
...stationAt(mark.chainage_m),
|
||||
structure: label,
|
||||
}));
|
||||
|
||||
function pipesToStations(pipes: ReadonlyArray<PipeProjection>): IrregularStation[] {
|
||||
return pipes.flatMap((pipe) => {
|
||||
const label =
|
||||
pipe.facility === "pipe"
|
||||
? structureLabel({
|
||||
structureType: "배관",
|
||||
pipeType: PIPE_DEFAULT_TYPE,
|
||||
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
|
||||
})
|
||||
: FACILITY_NAMES[pipe.facility];
|
||||
const id = `pipe-${pipe.chainage_m.toFixed(2)}`;
|
||||
return [
|
||||
{
|
||||
id,
|
||||
...stationAt(pipe.chainage_m),
|
||||
structure: label,
|
||||
structureType: "배관" as const,
|
||||
origin: "pipe" as const,
|
||||
},
|
||||
...spanStations(id, label, pipeStationMarks(pipe)),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
|
||||
function clearProjectedStations(): void {
|
||||
pipeStations = [];
|
||||
planPipes = [];
|
||||
irregularStations = [];
|
||||
deps.renderStationLines();
|
||||
deps.profilePanel().setIrregularStations([]);
|
||||
@@ -142,11 +89,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
irregularStations = stations;
|
||||
deps.renderStationLines();
|
||||
deps.profilePanel().setIrregularStations(stations);
|
||||
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
|
||||
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
|
||||
deps
|
||||
.profilePanel()
|
||||
.drainage.setPipeChainages(stations.filter(isPipeStation).map((entry) => entry.chainage_m));
|
||||
// 배수유역도로 되먹이지 않음(322-3) — 관 고침 · 지움은 폼 콜백이 배수 패널로 바로 감(관 정본이 원천)
|
||||
}
|
||||
|
||||
/* ── 구조물 정본(structures.json) ────────────────────────────────────
|
||||
@@ -162,41 +105,22 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
* 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅
|
||||
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
|
||||
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
|
||||
let pipeStations: IrregularStation[] = [];
|
||||
/** 측점 계획 입력 — 관(배수 패널이 알려 줌) · 종류 표(정의 기본값 · 카드 이름). */
|
||||
let planPipes: PipeProjection[] = [];
|
||||
let planTypes: StructureType[] = [];
|
||||
let structureTypeMap = new Map<
|
||||
string,
|
||||
{ type_id: string; group: string; name: string; placement: string; managed_by: string | null }
|
||||
>();
|
||||
|
||||
/** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations`
|
||||
* 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
||||
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
|
||||
return structures.flatMap((structure) => {
|
||||
const type = structureTypeMap.get(structure.type_id);
|
||||
const marks = structureStationMarks(structure, type);
|
||||
const anchor = marks.find((mark) => mark.role === "anchor");
|
||||
if (!anchor) return [];
|
||||
const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`;
|
||||
const label = type?.name ?? structure.type_id;
|
||||
return [
|
||||
{ id, ...stationAt(anchor.chainage_m), structure: label },
|
||||
...spanStations(id, label, marks),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이
|
||||
* 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을
|
||||
* 지우지 않게) · 물넘이포장도 측점을 심음(286-1). */
|
||||
/** 그래프 · 3D 측점 = 측점 계획 한 벌(322-3 · 카드 · B06 종단 · 서버와 같은 목록 · 같은 m 하나 · 관이 먼저).
|
||||
* 종류 표를 받기 전이면 미룸(받으면 다시 맞춤). */
|
||||
function syncCrossDrainStations(): void {
|
||||
const kept: IrregularStation[] = [];
|
||||
for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) {
|
||||
const taken =
|
||||
station.origin !== "pipe" &&
|
||||
kept.some((other) => Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M);
|
||||
if (!taken) kept.push(station);
|
||||
}
|
||||
applyIrregularStations(kept);
|
||||
if (!planTypes.length) return;
|
||||
const interval = deps.panel().values().stationInterval || 20;
|
||||
applyIrregularStations(
|
||||
plannedIrregular(planStations(planPipes, ownStructures, planTypes), interval),
|
||||
);
|
||||
}
|
||||
|
||||
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
|
||||
@@ -356,6 +280,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
]);
|
||||
deps.panel().structures.setTypes(allTypes, listPick("b05")); // 표 목록 both(322-1)
|
||||
deps.profilePanel().setStructureTypes(allTypes);
|
||||
planTypes = allTypes;
|
||||
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
||||
structureTypeMap = new Map(
|
||||
allTypes.map((type) => [
|
||||
@@ -410,7 +335,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
|
||||
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
|
||||
setPipes(pipes: PipeProjection[]): void {
|
||||
pipeStations = pipesToStations(pipes);
|
||||
planPipes = [...pipes];
|
||||
syncCrossDrainStations();
|
||||
pipeMarks = pipesToMarks(pipes);
|
||||
syncGraphStructures();
|
||||
|
||||
@@ -138,11 +138,12 @@ def _station_chainages(project_root: Path) -> list[float]:
|
||||
|
||||
def _stale_marks(project_root: Path, route_data_path: str) -> int:
|
||||
"""종단 정본의 구조물 측점과 **지금 관 · 구조물**이 어긋난 수 — 옮긴 관의 옛 자리 · 구조물이
|
||||
떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272). 0 이면 맞음."""
|
||||
떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272) · 같은 자리 다른 종류 · 이름(322-3 ·
|
||||
관 공식 · 정의 판이 바뀐 이름 포함 — 이름이 지금 정의 판에서 옴). 0 이면 맞음."""
|
||||
import json
|
||||
|
||||
planted = {round(value, 2) for value, _label in _planted(project_root, route_data_path)}
|
||||
standing: set[float] = set()
|
||||
planted = {(round(value, 2), label) for value, label in _planted(project_root, route_data_path)}
|
||||
standing: set[tuple[float, str]] = set()
|
||||
for path in sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -150,7 +151,8 @@ def _stale_marks(project_root: Path, route_data_path: str) -> int:
|
||||
continue
|
||||
for station in data.get("stations") or []:
|
||||
if station.get("kind") == "irregular" or station.get("structure"):
|
||||
standing.add(round(float(station.get("chainage_m") or 0.0), 2))
|
||||
at = round(float(station.get("chainage_m") or 0.0), 2)
|
||||
standing.add((at, str(station.get("structure") or "")))
|
||||
return len(standing ^ planted)
|
||||
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB
|
||||
* 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음.
|
||||
*
|
||||
* 심는 자리 규칙 = `plantedChainages`(서버 `resolve_extra_stations` 짝) · 카드 이름 = 서버 측점 이름 규칙
|
||||
* (`pipe_station_label` · 종류 이름) · 관 그림 = 짝 산식 `common_util_culvert_sets`.
|
||||
* 심는 자리 · 카드 이름 = 측점 계획 한 벌(`planStations` · 서버 `resolve_extra_stations` 짝 · 322-3) ·
|
||||
* 관 그림 = 짝 산식 `common_util_culvert_sets`. 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도
|
||||
* 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
@@ -27,11 +28,11 @@ import {
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
plantedChainages,
|
||||
planStations,
|
||||
SAME_STATION_M,
|
||||
structureStationMarks,
|
||||
type PlannedStation,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
import { cardTitle } from "../common_util/common_util_structure_catalog";
|
||||
import {
|
||||
previewStructureSections,
|
||||
type CrossSection,
|
||||
@@ -122,31 +123,15 @@ function bareOf(section: CrossSection): CrossSection {
|
||||
function decorate(
|
||||
fresh: CrossSection[],
|
||||
pipes: Pipes,
|
||||
structures: StructureInstance[],
|
||||
planned: readonly PlannedStation[],
|
||||
types: StructureType[],
|
||||
): void {
|
||||
const registry = registryDefaults(types);
|
||||
const byId = new Map(types.map((type) => [type.type_id, type]));
|
||||
// 카드 이름 = 표 카드 이름(322-1 · 배관 = 관종 D관경 · 빈칸 = 그 종류 정의 기본값 · 그 밖 = 종류 이름)
|
||||
const labels: Array<[number, string]> = [
|
||||
...pipes.flatMap((pipe) => {
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
const label = cardTitle(facility, pipe.options, registry[facility]);
|
||||
return pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label]);
|
||||
}),
|
||||
...structures.flatMap((structure) => {
|
||||
const type = byId.get(structure.type_id);
|
||||
const label = cardTitle(structure.type_id, structure.options);
|
||||
return structureStationMarks(structure, type).map((m): [number, string] => [
|
||||
m.chainage_m,
|
||||
label,
|
||||
]);
|
||||
}),
|
||||
];
|
||||
for (const section of fresh) {
|
||||
section.preview = true;
|
||||
const hit = labels.find(([at]) => near(at, section.chainage_m));
|
||||
if (hit) section.structure = hit[1];
|
||||
// 카드 이름 = 측점 계획 이름(322-3 · 같은 m 는 관이 먼저)
|
||||
const hit = planned.find((station) => near(station.chainage_m, section.chainage_m));
|
||||
if (hit) section.structure = hit.title;
|
||||
else delete section.structure; // 구조물이 다 빠진 일반 측점 자리(217)
|
||||
}
|
||||
const own = pipes.filter((pipe) =>
|
||||
@@ -192,7 +177,8 @@ export async function withDraftStations(
|
||||
]).catch(() => null);
|
||||
if (inputs) {
|
||||
const [pipes, structures, types] = inputs;
|
||||
const planted = plantedChainages(pipes, structures, types);
|
||||
const planned = planStations(pipes, structures, types, registryDefaults(types));
|
||||
const planted = planned.map((station) => station.chainage_m);
|
||||
// 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고
|
||||
// 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴
|
||||
const [oldPipes, oldStructures] = await savedOf(projectId).catch(
|
||||
@@ -222,16 +208,25 @@ export async function withDraftStations(
|
||||
(section.grid
|
||||
? replaced(section)
|
||||
: planted.every((at) => !near(at, section.chainage_m)) || replaced(section));
|
||||
stale = all.filter(isStale);
|
||||
const bare = stale.filter((section) => section.grid).map(bareOf);
|
||||
live = [...all.filter((section) => !isStale(section)), ...bare];
|
||||
// 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물 = 그 자리 구조물 없는 사본에 이름 · 형상(219) —
|
||||
// 구조물 측점(irregular)은 위 종류 대조가 맡음 · 옛 것은 빼 둠(초안이 없어지면 돌아옴)
|
||||
const retitled = all.filter(
|
||||
(section) =>
|
||||
section.kind !== "irregular" &&
|
||||
planned.some((station) => near(station.chainage_m, section.chainage_m)),
|
||||
);
|
||||
stale = [...all.filter(isStale), ...retitled];
|
||||
const bare = [...all.filter(isStale).filter((section) => section.grid), ...retitled].map(
|
||||
bareOf,
|
||||
);
|
||||
live = [...all.filter((section) => !stale.includes(section)), ...bare];
|
||||
const missing = planted.filter(
|
||||
(at, index) =>
|
||||
planted.findIndex((other) => near(other, at)) === index &&
|
||||
live.every((section) => !near(section.chainage_m, at)),
|
||||
);
|
||||
fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : [];
|
||||
if (fresh.length || bare.length) decorate([...fresh, ...bare], pipes, structures, types);
|
||||
if (fresh.length || bare.length) decorate([...fresh, ...bare], pipes, planned, types);
|
||||
}
|
||||
}
|
||||
if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
coversChainage,
|
||||
NO_SELECTION,
|
||||
PIPE_MATCH_M,
|
||||
followMovedPipe,
|
||||
readSelection,
|
||||
resolveSelection,
|
||||
selectDesign,
|
||||
@@ -97,7 +98,12 @@ export interface B06StructuresPanelDeps {
|
||||
/** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름 · span 끔 = 범위 테두리 없이 · 137-4). */
|
||||
markStructure?: (structureId: string | null, span?: boolean) => void;
|
||||
/** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */
|
||||
onMarks?: (structures: StructureInstance[], types: StructureType[]) => void;
|
||||
/** `pipes` = 관 지점(구간 포함) — 종단 띠 측점 계획(322-3). */
|
||||
onMarks?: (
|
||||
structures: StructureInstance[],
|
||||
types: StructureType[],
|
||||
pipes: ReadonlyArray<PipeFacilityItem>,
|
||||
) => void;
|
||||
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
|
||||
* 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */
|
||||
applyPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
@@ -193,7 +199,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
};
|
||||
/** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */
|
||||
const pushMarks = (): void =>
|
||||
deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes);
|
||||
deps.onMarks?.(
|
||||
[...structures, ...pipesToStructureMarks(pipeFacilities)],
|
||||
markTypes,
|
||||
pipeFacilities,
|
||||
);
|
||||
// 폼 한 벌(계획서 24-3b) — 횡단 보기가 보일 때 폼 알림을 받는 동작만 건다. 상세 옵션 칸은
|
||||
// 한 벌이 보기에 따라 그린다(횡단 보기에서만).
|
||||
const section = sharedStructures(deps.shared);
|
||||
@@ -507,6 +517,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
}
|
||||
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
|
||||
currentChainageM = to;
|
||||
followMovedPipe(deps.projectId, fromChainageM, to); // 고른 관이면 고름도 새 자리로(322-3)
|
||||
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
|
||||
@@ -642,11 +642,11 @@ export function createSectionView(
|
||||
setStructureEdit(edit) {
|
||||
structureEdit = edit;
|
||||
},
|
||||
setStructureMarks(structures, types) {
|
||||
setStructureMarks(structures, types, pipes) {
|
||||
spanMarks = structures;
|
||||
spanTypes = types;
|
||||
syncSpanHighlights();
|
||||
profile.setMarks(structures, types);
|
||||
profile.setMarks(structures, types, pipes);
|
||||
// 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다
|
||||
// (`__corridorBuild` 등과 같은 용도).
|
||||
(window as unknown as { __b06Marks?: unknown }).__b06Marks = {
|
||||
|
||||
@@ -47,6 +47,8 @@ import {
|
||||
moveMarkById,
|
||||
type SectionStructureEdit,
|
||||
} from "./B06_Section_UI_Section_View_Menu";
|
||||
import { planStations, type PlanPipe } from "../common_util/common_util_structure_station";
|
||||
import { plannedIrregular } from "../A00_Common/design/design_structure_status";
|
||||
|
||||
/** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */
|
||||
const MATCH_M = PIPE_MATCH_M;
|
||||
@@ -79,6 +81,7 @@ export interface SectionProfile {
|
||||
setMarks: (
|
||||
structures: ReadonlyArray<StructureInstance>,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
pipes?: ReadonlyArray<PlanPipe>,
|
||||
) => void;
|
||||
/** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */
|
||||
redraw: () => void;
|
||||
@@ -88,25 +91,23 @@ export interface SectionProfile {
|
||||
selectMark: (structureId: string | null) => void;
|
||||
}
|
||||
|
||||
/** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */
|
||||
function pipeStations(
|
||||
/** 종단 측점선 = 측점 계획 한 벌(322-3 · 관 · 측점 심는 구조물 모두 · 종단 보기 · 카드와 같은 목록).
|
||||
* 관 구간(독립 기슭막이 시작 · 끝)은 받은 관 목록에서 · 없으면 알약에서 기준만. */
|
||||
function planMarkStations(
|
||||
marks: ReadonlyArray<StructureInstance>,
|
||||
pipes: ReadonlyArray<PlanPipe> | null,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
return marks.filter(isPipeMark).map((mark) => {
|
||||
const chainage = structureAnchorM(mark);
|
||||
const station = Math.floor(chainage / intervalM);
|
||||
return {
|
||||
id: String(mark.structure_id),
|
||||
station,
|
||||
remainder: chainage - station * intervalM,
|
||||
chainage_m: chainage,
|
||||
structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관",
|
||||
structureType: "배관",
|
||||
origin: "pipe",
|
||||
} as IrregularStation;
|
||||
});
|
||||
const pipeList =
|
||||
pipes ??
|
||||
marks.filter(isPipeMark).map((mark) => ({
|
||||
chainage_m: structureAnchorM(mark),
|
||||
facility: mark.type_id,
|
||||
options: mark.options,
|
||||
}));
|
||||
const structures = marks.filter((mark) => !isPipeMark(mark));
|
||||
return plannedIrregular(planStations(pipeList, structures, types), intervalM);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,6 +142,7 @@ export function createSectionProfile(
|
||||
let profile: DesignProfile | null = null;
|
||||
let detail: SectionDetailResponse | null = null;
|
||||
let marks: ReadonlyArray<StructureInstance> = [];
|
||||
let markPipes: ReadonlyArray<PlanPipe> | null = null;
|
||||
/** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */
|
||||
let markedId: string | null = null;
|
||||
let types: ReadonlyArray<StructureType> = [];
|
||||
@@ -180,7 +182,7 @@ export function createSectionProfile(
|
||||
if (!profile) return;
|
||||
profile.setStructureTypes([...types]);
|
||||
profile.setStructures([...marks]);
|
||||
stations = pipeStations(marks, types, intervalM);
|
||||
stations = planMarkStations(marks, markPipes, types, intervalM);
|
||||
profile.setIrregularStations(stations);
|
||||
// 판(배수유역도)이 없는 보기 — 관 목록을 직접 넘겨 최소 흙덮기를 판정한다.
|
||||
profile.setMinCoverPipes(
|
||||
@@ -302,9 +304,10 @@ export function createSectionProfile(
|
||||
}
|
||||
syncMarks();
|
||||
},
|
||||
setMarks(nextMarks, nextTypes) {
|
||||
setMarks(nextMarks, nextTypes, nextPipes) {
|
||||
marks = nextMarks;
|
||||
types = nextTypes;
|
||||
markPipes = nextPipes ?? null;
|
||||
syncMarks();
|
||||
},
|
||||
redraw() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { PlanPipe } from "../common_util/common_util_structure_station";
|
||||
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
|
||||
/**
|
||||
@@ -64,6 +65,8 @@ export interface SectionViewController {
|
||||
setStructureMarks: (
|
||||
structures: ReadonlyArray<StructureInstance>,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
/** 관 지점(구간 포함) — 종단 띠 측점 계획(322-3 · 없으면 알약에서 기준만). */
|
||||
pipes?: ReadonlyArray<PlanPipe>,
|
||||
) => void;
|
||||
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
|
||||
setStructureEdit: (edit: SectionStructureEdit | null) => void;
|
||||
|
||||
@@ -99,6 +99,13 @@ def type_ids_with(**match: str) -> set[str]:
|
||||
return {key for key, row in catalog().items() if all(row[k] == v for k, v in match.items())}
|
||||
|
||||
|
||||
def shape_needs(type_id: str) -> list[str]:
|
||||
"""형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — TS `shapeNeeds` 짝."""
|
||||
row = catalog_row(type_id)
|
||||
part = load_parts()["parts"]["형상"].get(row["shape"]) if row else None
|
||||
return list(part.get("필수 칸", [])) if part else []
|
||||
|
||||
|
||||
def card_title(type_id: str, options: dict[str, Any] | None) -> str | None:
|
||||
"""카드 · 측점 이름 — 카드이름 pipe = 「관종 D관경」(빈칸 = 그 종류 정의 기본값) · 그 밖 = 이름.
|
||||
|
||||
|
||||
@@ -170,5 +170,12 @@ export function cardTitle(
|
||||
return `${value("pipe_kind") ?? ""} D${Math.trunc(Number(value("pipe_diameter_mm"))) || 0}`;
|
||||
}
|
||||
|
||||
/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */
|
||||
export function shapeNeeds(typeId: string): readonly string[] {
|
||||
const shape = catalogRow(typeId)?.shape ?? "none";
|
||||
const box = (PARTS as unknown as Parts).parts.형상 as Record<string, { "필수 칸"?: string[] }>;
|
||||
return Object.hasOwn(box, shape) ? (box[shape]["필수 칸"] ?? []) : [];
|
||||
}
|
||||
|
||||
/** 관 공식 이름의 관종들(정의 선택지) — 표시 이름이 「배수관」으로 줄일 이름을 알아봄. */
|
||||
export const pipeTitleKinds = (): readonly string[] => pipeKinds;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 표 읽기 = common_util_structure_catalog.ts · .py(짝).",
|
||||
"comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 표 읽기 = common_util_structure_catalog.ts · .py(짝).",
|
||||
"parts": {
|
||||
"횡단도": {
|
||||
"anchor": {
|
||||
@@ -48,7 +48,8 @@
|
||||
"outlet_revet_height_m"
|
||||
],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"]
|
||||
"정본": ["pipe_points"],
|
||||
"필수 칸": ["pipe_diameter_mm"]
|
||||
},
|
||||
"box": {
|
||||
"이름": "BOX 본체 · 날개벽",
|
||||
@@ -93,7 +94,8 @@
|
||||
"이름": "물넘이 포장(파인 노면 · 도수로)",
|
||||
"읽는 칸": ["ford_width_m", "ford_height_m", "ford_slope_deg", "ford_slope_pct"],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"]
|
||||
"정본": ["pipe_points"],
|
||||
"필수 칸": ["ford_height_m"]
|
||||
},
|
||||
"hidden_revet": {
|
||||
"이름": "독립 기슭막이(관 숨김 벽)",
|
||||
@@ -110,7 +112,8 @@
|
||||
"face_slope_ratio"
|
||||
],
|
||||
"자리꼴": ["interval"],
|
||||
"정본": ["pipe_points"]
|
||||
"정본": ["pipe_points"],
|
||||
"필수 칸": ["height_m"]
|
||||
},
|
||||
"wall": {
|
||||
"이름": "벽(옹벽 · 흙막이 · 돌쌓기)",
|
||||
@@ -126,7 +129,8 @@
|
||||
"face_slope_ratio"
|
||||
],
|
||||
"자리꼴": ["interval"],
|
||||
"정본": ["structures"]
|
||||
"정본": ["structures"],
|
||||
"필수 칸": ["height_m"]
|
||||
},
|
||||
"berm": {
|
||||
"이름": "소단(사면 계단)",
|
||||
@@ -138,7 +142,8 @@
|
||||
"이름": "맹암거",
|
||||
"읽는 칸": ["culvert_height_m", "culvert_slope_pct"],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["structures"]
|
||||
"정본": ["structures"],
|
||||
"필수 칸": ["culvert_height_m"]
|
||||
},
|
||||
"pavement": {
|
||||
"이름": "포장층",
|
||||
@@ -289,7 +294,10 @@
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "wall",
|
||||
"형상칸": { "form": "콘크리트", "weep": true },
|
||||
"형상칸": {
|
||||
"form": "콘크리트",
|
||||
"weep": true
|
||||
},
|
||||
"3D": "wall"
|
||||
},
|
||||
"masonry_wet": {
|
||||
@@ -297,7 +305,9 @@
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "wall",
|
||||
"형상칸": { "form": "돌쌓기(찰)" },
|
||||
"형상칸": {
|
||||
"form": "돌쌓기(찰)"
|
||||
},
|
||||
"3D": "wall"
|
||||
},
|
||||
"masonry_dry": {
|
||||
@@ -305,7 +315,9 @@
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "wall",
|
||||
"형상칸": { "form": "돌쌓기(메)" },
|
||||
"형상칸": {
|
||||
"form": "돌쌓기(메)"
|
||||
},
|
||||
"3D": "wall"
|
||||
},
|
||||
"soil_guard": {
|
||||
@@ -313,7 +325,9 @@
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "wall",
|
||||
"형상칸": { "form": "통나무·목재틀" },
|
||||
"형상칸": {
|
||||
"form": "통나무·목재틀"
|
||||
},
|
||||
"3D": "wall"
|
||||
},
|
||||
"boulder_masonry": {
|
||||
@@ -321,7 +335,9 @@
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "wall",
|
||||
"형상칸": { "form": "돌쌓기(메)" },
|
||||
"형상칸": {
|
||||
"form": "돌쌓기(메)"
|
||||
},
|
||||
"3D": "wall"
|
||||
},
|
||||
"berm": {
|
||||
@@ -395,7 +411,13 @@
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"curb": { "목록": "both", "횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none" },
|
||||
"curb": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"hazard_sign": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
@@ -480,6 +502,12 @@
|
||||
"형상": "pavement",
|
||||
"3D": "surface"
|
||||
},
|
||||
"etc": { "목록": "both", "횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none" }
|
||||
"etc": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
만들기가 한 측점으로 합침(구조물 측점이 위 · `B05_Profile_Engine_Sections_Core`).
|
||||
234-1 의 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수 · 사용자 167).
|
||||
TS 짝 = `common_util/common_util_structure_station.ts`
|
||||
(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김).
|
||||
(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김 ·
|
||||
측점 계획 `plan_stations` ↔ `planStations` · 322-3).
|
||||
거울 시험 = `resources/tester/test_234_1_structure_station.py`.
|
||||
일반 측점 = 격자(간격) · 시종점 — 끝(노선 길이)을 넘지 않게 자르는 데만 씀.
|
||||
"""
|
||||
@@ -67,3 +68,100 @@ def snap_candidates(
|
||||
x, y, _ = interpolate_vertex(list(vertices), at)
|
||||
snapped.append(replace(candidate, chainage_m=at, x=x, y=y))
|
||||
return snapped
|
||||
|
||||
|
||||
# ── 측점 계획 한 벌(322-3 · TS `planStations` 짝 · 거울 = test_322_3_plan_mirror.py) ──
|
||||
|
||||
#: 같은 측점으로 볼 거리(m) — TS `SAME_STATION_M` 짝
|
||||
SAME_STATION_M = 0.005
|
||||
|
||||
|
||||
def _empty(value: object) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def _station_marks(
|
||||
start: object, anchor: object, end: object, span3: bool
|
||||
) -> list[tuple[str, float]]:
|
||||
"""TS `marksOf` 짝 — 수인 자리만 (역할, 누가거리)."""
|
||||
marks = [("start", start), ("anchor", anchor), ("end", end)] if span3 else [("anchor", anchor)]
|
||||
return [
|
||||
(role, float(at))
|
||||
for role, at in marks
|
||||
if isinstance(at, (int, float)) and not isinstance(at, bool) and math.isfinite(at)
|
||||
]
|
||||
|
||||
|
||||
def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list[dict[str, object]]:
|
||||
"""관 · 구조물(현황) + 구조물 표 → 측점 계획 `[{chainage_m, role, title, owners, blank}]`.
|
||||
|
||||
오름차순 · 같은 m(0.005) 는 하나 — 이름 = 관이 있으면 첫 관 · 아니면 앞선 구조물(표 카드 이름)
|
||||
· 덮는 구조물 id(관 = 자리 이름 `pipe-{누가거리}` · 구조물 = structure_id) · 빈칸 경고(형상
|
||||
부품 필수 칸 중 값이 빈 옵션 키). 표에 없는 종류는 뺌.
|
||||
"""
|
||||
from common_util.common_util_structure_catalog import card_title, catalog_row, shape_needs
|
||||
|
||||
def blank(type_id: str, options: dict | None) -> list[str]:
|
||||
"""형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝)."""
|
||||
return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))]
|
||||
|
||||
marks: list[dict[str, object]] = []
|
||||
for pipe in pipes:
|
||||
facility = getattr(pipe, "facility", None) or "pipe"
|
||||
row = catalog_row(facility)
|
||||
if row is None:
|
||||
continue
|
||||
options = getattr(pipe, "options", None)
|
||||
chainage = float(getattr(pipe, "chainage_m"))
|
||||
station = row["station"]
|
||||
for role, at in (
|
||||
[]
|
||||
if station == "none"
|
||||
else _station_marks(
|
||||
getattr(pipe, "start_m", None),
|
||||
chainage,
|
||||
getattr(pipe, "end_m", None),
|
||||
station == "span3",
|
||||
)
|
||||
):
|
||||
marks.append(
|
||||
{
|
||||
"chainage_m": at,
|
||||
"role": role,
|
||||
"title": card_title(facility, options),
|
||||
"owner": getattr(pipe, "id", None) or f"pipe-{chainage:.2f}",
|
||||
"blank": blank(facility, options),
|
||||
"pipe": True,
|
||||
}
|
||||
)
|
||||
for item in structures:
|
||||
row = catalog_row(item.type_id)
|
||||
if row is None or row["owner"] == "pipe_points" or row["station"] == "none":
|
||||
continue
|
||||
anchor = item.chainage_m if item.chainage_m is not None else item.start_m
|
||||
found = _station_marks(item.start_m, anchor, item.end_m, row["station"] == "span3")
|
||||
base = next((at for role, at in found if role == "anchor"), 0.0)
|
||||
for role, at in found:
|
||||
marks.append(
|
||||
{
|
||||
"chainage_m": at,
|
||||
"role": role,
|
||||
"title": card_title(item.type_id, item.options),
|
||||
"owner": item.structure_id or f"structure-{base:.2f}",
|
||||
"blank": blank(item.type_id, item.options),
|
||||
"pipe": False,
|
||||
}
|
||||
)
|
||||
marks.sort(key=lambda mark: mark["chainage_m"])
|
||||
out: list[dict[str, object]] = []
|
||||
for mark in marks:
|
||||
last = out[-1] if out else None
|
||||
if last is not None and abs(mark["chainage_m"] - last["chainage_m"]) < SAME_STATION_M:
|
||||
if mark["pipe"] and not last["pipe"]:
|
||||
last["title"], last["pipe"] = mark["title"], True
|
||||
if mark["owner"] not in last["owners"]:
|
||||
last["owners"].append(mark["owner"])
|
||||
last["blank"] += [key for key in mark["blank"] if key not in last["blank"]]
|
||||
continue
|
||||
out.append({**mark, "owners": [mark["owner"]], "blank": list(mark["blank"])})
|
||||
return [{k: v for k, v in station.items() if k not in ("pipe", "owner")} for station in out]
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
*
|
||||
* 심는 측점 = 구조물 표(catalog) 횡단도 부품(322-1) — anchor = 기준 하나 · span3 = 시작 · 기준 · 끝 · none = 없음.
|
||||
*
|
||||
* 측점 계획 한 벌(322-3 · `planStations`) — 관 · 구조물(현황) + 표 → 측점 · 역할 · 카드 이름 · 덮는 구조물 id ·
|
||||
* 빈칸 경고 · 같은 m 는 하나(관이 먼저 · 이름 = 관 → 앞선 것). 파이썬 짝 = `common_util_structure_station.py`
|
||||
* `plan_stations`(서버 `resolve_extra_stations`) · 거울 시험 = `resources/tester/test_322_3_plan_mirror.py`.
|
||||
* 카드 목록 · B05 종단 · B06 종단 띠 · 3D 막대 · 미리 뜬 횡단 이름이 이 목록 하나를 씀.
|
||||
*
|
||||
* 측점 한 벌(272) — 측점을 심는 관 · 구조물(`plantedChainages`) = 서버 `resolve_extra_stations`
|
||||
* (B05_Profile_Engine_Sections.py) 짝 · 같은 m 면 측점 하나 · 구조물이 위(`oneStationList`) = 서버
|
||||
* `_merge_irregular_into_longitudinal`(B05_Profile_Router_Confirm.py) 짝. 거울 시험 =
|
||||
@@ -18,7 +23,7 @@
|
||||
* 이 목록 하나를 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { catalogRow } from "./common_util_structure_catalog";
|
||||
import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catalog";
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
@@ -133,21 +138,100 @@ export function structureStationMarks(
|
||||
return marksOf(structure.start_m, anchor, structure.end_m, row.station === "span3");
|
||||
}
|
||||
|
||||
/** 측점을 심는 자리 전부(오름차순 · 겹침 그대로) — 서버 `resolve_extra_stations` 의 누가거리. */
|
||||
export function plantedChainages(
|
||||
pipes: ReadonlyArray<PlantingPipe>,
|
||||
structures: ReadonlyArray<PlantingStructure>,
|
||||
types: ReadonlyArray<PlantingType>,
|
||||
): number[] {
|
||||
/* ── 측점 계획 한 벌(322-3 · 파이썬 `plan_stations` 짝) ── */
|
||||
|
||||
export interface PlanPipe extends PlantingPipe {
|
||||
/** 관 세션 id — 없으면 자리로 지음(`pipe-{누가거리 2자리}`). */
|
||||
id?: string | null;
|
||||
options?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PlanStructure extends PlantingStructure {
|
||||
structure_id?: string | null;
|
||||
options?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PlanType extends PlantingType {
|
||||
options?: ReadonlyArray<{ key: string; default?: unknown }>;
|
||||
}
|
||||
|
||||
/** 측점 한 곳 — 같은 m 에 놓인 관 · 구조물을 함께 덮음. */
|
||||
export interface PlannedStation {
|
||||
chainage_m: number;
|
||||
role: StationMark["role"];
|
||||
/** 카드 · 측점 이름 — 관이 있으면 첫 관 · 아니면 앞선 구조물(표 카드 이름). */
|
||||
title: string;
|
||||
/** 덮는 구조물 id(관 = 세션 id · 구조물 = structure_id). */
|
||||
owners: string[];
|
||||
/** 빈칸 경고 — 형상 부품 필수 칸 중 값이 빈 옵션 키(비면 그 그림이 안 섬). */
|
||||
blank: string[];
|
||||
}
|
||||
|
||||
const empty = (value: unknown): boolean => value === undefined || value === null || value === "";
|
||||
|
||||
/** 형상 부품 필수 칸 중 값이 빈 키(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 — 정의 기본값으로 안 채움). */
|
||||
const blankKeys = (typeId: string, options: Record<string, unknown> | null | undefined): string[] =>
|
||||
shapeNeeds(typeId).filter((key) => empty(options?.[key]));
|
||||
|
||||
/** 관 · 구조물 → 측점 계획(오름차순 · 같은 m 하나). 표에 없는 종류는 뺌. `registry` = 종류 → 옵션
|
||||
* 기본값(카드 이름 빈칸 · 없으면 `types` 정의 기본값 · 횡단 관 세트 `registryDefaults` 와 같은 표). */
|
||||
export function planStations(
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
registry?: Readonly<Record<string, Record<string, unknown>>>,
|
||||
): PlannedStation[] {
|
||||
const byId = new Map(types.map((type) => [type.type_id, type]));
|
||||
return [
|
||||
...pipes.flatMap(pipeStationMarks),
|
||||
...structures.flatMap((structure) =>
|
||||
structureStationMarks(structure, byId.get(structure.type_id)),
|
||||
),
|
||||
]
|
||||
.map((mark) => mark.chainage_m)
|
||||
.sort((a, b) => a - b);
|
||||
const defaults = (typeId: string): Record<string, unknown> =>
|
||||
registry?.[typeId] ??
|
||||
Object.fromEntries((byId.get(typeId)?.options ?? []).map((o) => [o.key, o.default]));
|
||||
type Mark = StationMark & { title: string; owner: string; blank: string[]; pipe: boolean };
|
||||
const marks: Mark[] = [
|
||||
...pipes.flatMap((pipe) => {
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
if (!catalogRow(facility)) return [];
|
||||
const title = cardTitle(facility, pipe.options, defaults(facility));
|
||||
const owner = pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`;
|
||||
const blank = blankKeys(facility, pipe.options);
|
||||
return pipeStationMarks(pipe).map((m) => ({ ...m, title, owner, blank, pipe: true }));
|
||||
}),
|
||||
...structures.flatMap((structure) => {
|
||||
const marks = structureStationMarks(structure, byId.get(structure.type_id));
|
||||
const anchor = marks.find((m) => m.role === "anchor")?.chainage_m ?? 0;
|
||||
const title = cardTitle(structure.type_id, structure.options, defaults(structure.type_id));
|
||||
const owner = structure.structure_id ?? `structure-${anchor.toFixed(2)}`;
|
||||
const blank = blankKeys(structure.type_id, structure.options);
|
||||
return marks.map((m) => ({ ...m, title, owner, blank, pipe: false }));
|
||||
}),
|
||||
].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
const out: Array<PlannedStation & { pipe: boolean }> = [];
|
||||
for (const mark of marks) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && Math.abs(mark.chainage_m - last.chainage_m) < SAME_STATION_M) {
|
||||
if (mark.pipe && !last.pipe) Object.assign(last, { title: mark.title, pipe: true });
|
||||
if (!last.owners.includes(mark.owner)) last.owners.push(mark.owner);
|
||||
for (const key of mark.blank) if (!last.blank.includes(key)) last.blank.push(key);
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
chainage_m: mark.chainage_m,
|
||||
role: mark.role,
|
||||
title: mark.title,
|
||||
owners: [mark.owner],
|
||||
blank: [...mark.blank],
|
||||
pipe: mark.pipe,
|
||||
});
|
||||
}
|
||||
return out.map(({ pipe: _pipe, ...station }) => station);
|
||||
}
|
||||
|
||||
/** 측점을 심는 자리 전부(오름차순 · 같은 m 하나) — 측점 계획의 누가거리(서버 `resolve_extra_stations`). */
|
||||
export function plantedChainages(
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
): number[] {
|
||||
return planStations(pipes, structures, types).map((station) => station.chainage_m);
|
||||
}
|
||||
|
||||
/* ── 측점 한 벌(272 · 서버 `_merge_irregular_into_longitudinal` 짝) ── */
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`);
|
||||
@@ -208,6 +209,7 @@ function makeLoader(real, stubs = {}) {
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const need = (spec) => {
|
||||
if (CATALOG.isCatalogRequest(spec)) return CATALOG;
|
||||
if (spec in stubs) return stubs[spec];
|
||||
let target = null;
|
||||
if (spec.startsWith("@util/")) target = path.join(ROOT, "common_util", `${spec.slice(6)}.ts`);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/* PLAN 322-3 카드 목록 흐름 헬퍼 — 진짜 `B06_Section_Section_Drafts.ts`(withDraftStations) + 측점 계획 ·
|
||||
* 구조물 표 · 관 세트 산식을 그 자리에서 트랜스파일해 Node 로 돌림(서버 · 세션만 흉내) · 결과 = 표준출력 JSON.
|
||||
* 흐름 일곱 = 놓기 · 일반 측점에 놓기(219) · 옮기기 · 같은 군 바꿈 · 정본 넘는 바꿈 · 지우기 · 저장 뒤 다시 열기.
|
||||
* 짝 = `test_322_3_flow.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 DEF = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"),
|
||||
);
|
||||
const TYPES = DEF.types.map((t) => ({ managed_by: null, enabled: true, ...t }));
|
||||
|
||||
const state = { savedPipes: [], savedStructures: [], draftPipes: null, draftStructures: null };
|
||||
const memory = new Map();
|
||||
const store = {
|
||||
getItem: (k) => (memory.has(k) ? memory.get(k) : null),
|
||||
setItem: (k, v) => memory.set(k, String(v)),
|
||||
removeItem: (k) => memory.delete(k),
|
||||
key: (i) => [...memory.keys()][i] ?? null,
|
||||
get length() {
|
||||
return memory.size;
|
||||
},
|
||||
};
|
||||
globalThis.window = { sessionStorage: store, localStorage: store, addEventListener: () => {} };
|
||||
globalThis.sessionStorage = store;
|
||||
globalThis.localStorage = store;
|
||||
const file = (...parts) => path.resolve(ROOT, ...parts);
|
||||
const STUBS = new Map([
|
||||
[
|
||||
file("B04_PreProcess", "B04_PreProcess_Api_Fetch.ts"),
|
||||
{ fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }) },
|
||||
],
|
||||
[
|
||||
file("B05_Profile", "B05_Profile_Api_Structures.ts"),
|
||||
{
|
||||
fetchStructures: async () => ({ structures: state.savedStructures }),
|
||||
fetchStructureTypes: async () => TYPES,
|
||||
readPendingStructures: () => state.draftStructures,
|
||||
},
|
||||
],
|
||||
[
|
||||
file("B05_Profile", "B05_Profile_Api_Pipes_Draft.ts"),
|
||||
{ readPendingPipes: () => state.draftPipes },
|
||||
],
|
||||
[
|
||||
file("B06_Section", "B06_Section_Api_Fetch.ts"),
|
||||
{
|
||||
previewStructureSections: async (_p, chainages) =>
|
||||
chainages.map((at) => ({ chainage_m: at, kind: "irregular", station_id: `p${at}` })),
|
||||
},
|
||||
],
|
||||
]);
|
||||
const cache = new Map();
|
||||
function load(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(".json")) return JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const need = (spec) => {
|
||||
const alias = spec.startsWith("@util/") ? path.join(ROOT, "common_util", spec.slice(6)) : null;
|
||||
const base = alias ?? path.join(path.dirname(full), spec);
|
||||
const hit = [`${base}.ts`, base.replace(/\.js$/, ".ts"), base].find(
|
||||
(p) =>
|
||||
(STUBS.has(path.resolve(p)) || fs.existsSync(p)) &&
|
||||
!fs.statSync(p, { throwIfNoEntry: false })?.isDirectory(),
|
||||
);
|
||||
if (!hit) throw new Error(`모름: ${spec} (${full})`);
|
||||
return load(hit);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, need);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
load(file("common_util", "common_util_structure_catalog.ts")).useCatalogDefinition(DEF);
|
||||
const drafts = load(file("B06_Section", "B06_Section_Section_Drafts.ts"));
|
||||
|
||||
const grid = () =>
|
||||
Array.from({ length: 11 }, (_v, i) => ({
|
||||
chainage_m: i * 20,
|
||||
kind: "regular",
|
||||
station_id: `g${i * 20}`,
|
||||
}));
|
||||
const irregular = (at, structure, extra = {}) => ({
|
||||
chainage_m: at,
|
||||
kind: "irregular",
|
||||
station_id: `i${at}`,
|
||||
structure,
|
||||
...extra,
|
||||
});
|
||||
const wall = (type_id, id = "w1") => ({
|
||||
structure_id: id,
|
||||
type_id,
|
||||
placement: "interval",
|
||||
start_m: 93,
|
||||
chainage_m: 101,
|
||||
end_m: 113,
|
||||
options: { height_m: 3 },
|
||||
});
|
||||
const summary = (detail) =>
|
||||
detail.cross_sections.map((s) => [
|
||||
s.chainage_m,
|
||||
s.structure ?? null,
|
||||
!!s.preview,
|
||||
["culvert", "ford", "box"].filter((k) => s[k]),
|
||||
]);
|
||||
|
||||
let route = 0;
|
||||
async function run(setup, detail) {
|
||||
Object.assign(state, {
|
||||
savedPipes: [],
|
||||
savedStructures: [],
|
||||
draftPipes: null,
|
||||
draftStructures: null,
|
||||
...setup,
|
||||
});
|
||||
drafts.forgetDraftStations("P");
|
||||
route += 1;
|
||||
await drafts.withDraftStations(detail, "P", route);
|
||||
return summary(detail);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const out = {};
|
||||
out.place = await run({ draftStructures: [wall("retaining_wall")] }, { cross_sections: grid() });
|
||||
out.gridPlace = await run(
|
||||
{
|
||||
draftStructures: [
|
||||
{
|
||||
structure_id: "u1",
|
||||
type_id: "cross_underdrain",
|
||||
placement: "point",
|
||||
chainage_m: 100,
|
||||
options: {},
|
||||
},
|
||||
],
|
||||
draftPipes: [{ chainage_m: 120, options: { pipe_diameter_mm: "800" } }],
|
||||
},
|
||||
{ cross_sections: grid() },
|
||||
);
|
||||
const pipeSet = { culvert: { type: "pipe" } };
|
||||
out.move = await run(
|
||||
{
|
||||
savedPipes: [{ chainage_m: 61, options: { pipe_diameter_mm: "1000" } }],
|
||||
draftPipes: [{ chainage_m: 75, options: { pipe_diameter_mm: "1000" } }],
|
||||
},
|
||||
{ cross_sections: [...grid(), irregular(61, "파형강관 D1000", pipeSet)] },
|
||||
);
|
||||
const walls = () => [irregular(93, "옹벽"), irregular(101, "옹벽"), irregular(113, "옹벽")];
|
||||
out.sameGroup = await run(
|
||||
{ savedStructures: [wall("retaining_wall")], draftStructures: [wall("soil_guard")] },
|
||||
{ cross_sections: [...grid(), ...walls()] },
|
||||
);
|
||||
out.crossOwner = await run(
|
||||
{
|
||||
savedPipes: [{ chainage_m: 101, options: { pipe_diameter_mm: "1000" } }],
|
||||
draftPipes: [],
|
||||
draftStructures: [
|
||||
{
|
||||
structure_id: "u2",
|
||||
type_id: "cross_underdrain",
|
||||
placement: "point",
|
||||
chainage_m: 101,
|
||||
options: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cross_sections: [...grid(), irregular(101, "파형강관 D1000", pipeSet)] },
|
||||
);
|
||||
const kept = { cross_sections: [...grid(), ...walls()] };
|
||||
out.remove = await run({ savedStructures: [wall("retaining_wall")], draftStructures: [] }, kept);
|
||||
// 저장 뒤 다시 열기 — 초안 없음 = 서버 목록 그대로(빼 둔 옛 자리 돌아옴)
|
||||
out.reopen = await run({ savedStructures: [wall("retaining_wall")] }, kept);
|
||||
// 관을 옮겨도 고름 유지(322-3) — 고른 관이면 새 자리 · 다른 구조물 고름은 그대로
|
||||
const sel = load(file("A00_Common", "design", "design_selection.ts"));
|
||||
sel.selectDesign("P", { chainageM: 61, structureId: "pipe-61.00" });
|
||||
sel.followMovedPipe("P", 61, 75);
|
||||
const moved = sel.readSelection("P");
|
||||
sel.selectDesign("P", { chainageM: 101, structureId: "w1" });
|
||||
sel.followMovedPipe("P", 101, 120);
|
||||
out.follow = [moved, sel.readSelection("P")];
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.stack ?? error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/* PLAN 322-3 측점 계획 한 벌 거울 헬퍼 — 진짜 `common_util_structure_station.ts`(planStations) ·
|
||||
* `common_util_structure_catalog.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON.
|
||||
* 입력 = argv[2] JSON `{definition, types, cases: {이름: {pipes, structures}}}`. 짝 = `test_322_3_plan_mirror.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 load(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const need = (spec) => {
|
||||
const base = path.join(path.dirname(full), spec);
|
||||
const hit = [base, `${base}.ts`, base.replace(/\.js$/, ".ts")].find(
|
||||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||||
);
|
||||
if (!hit) throw new Error(`모름: ${spec} (${full})`);
|
||||
return load(hit);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, need);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const catalog = load(path.join(ROOT, "common_util", "common_util_structure_catalog.ts"));
|
||||
const station = load(path.join(ROOT, "common_util", "common_util_structure_station.ts"));
|
||||
catalog.useCatalogDefinition({ types: input.types, ...input.definition });
|
||||
const out = {};
|
||||
for (const [name, { pipes, structures }] of Object.entries(input.cases))
|
||||
out[name] = station.planStations(pipes, structures, input.types);
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -50,7 +50,7 @@ STRUCTURES = [
|
||||
"start_m": 500.0,
|
||||
"end_m": 530.0,
|
||||
},
|
||||
{ # 옹벽 끝 530 과 같은 자리에서 시작 — 겹친 자리도 그대로 셈(합치기는 측점 만들기 몫)
|
||||
{ # 옹벽 끝 530 과 같은 자리에서 시작 — 같은 자리는 측점 하나(322-3 측점 계획 · 이름 = 앞선 옹벽)
|
||||
"type_id": "berm",
|
||||
"placement": "interval",
|
||||
"chainage_m": 545.0,
|
||||
@@ -67,7 +67,7 @@ STRUCTURES = [
|
||||
},
|
||||
{"type_id": "evac_road", "placement": "point", "chainage_m": 720.0},
|
||||
]
|
||||
EXPECTED = [95, 160, 230, 300, 395, 400, 410, 500, 510, 530, 530, 545, 560]
|
||||
EXPECTED = [95, 160, 230, 300, 395, 400, 410, 500, 510, 530, 545, 560]
|
||||
|
||||
# 서버 종단 측점(맞추기 전) — 격자 0~100 · 옛 관 측점 95.38 · 구조물이 떠난 격자 자리 40(`grid`) ·
|
||||
# 옛 판 「regular + 글」 60 · 새 목록 = 격자 위 100(구조물이 위) · 새 자리 35
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""PLAN 322-3 카드 목록 흐름 일곱 — 초안 측점 맞춤(`withDraftStations`)이 측점 계획 한 벌을 따름.
|
||||
|
||||
놓기 · 일반 측점에 놓기(219 흠 — No.N+0 자리 새 구조물 = 이름 칩 · 관 그림) · 옮기기 · 같은 군 바꿈 ·
|
||||
정본 넘는 바꿈(관 → 맹암거) · 지우기 · 저장 뒤 다시 열기(빼 둔 옛 자리 돌아옴).
|
||||
각 줄 = [측점, 카드 이름, 미리 뜬 사본, 관 계열 그림 열쇠]. TS 쪽 = `helper_322_3_flow.cjs`(Node).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
ROOT = HERE.parent.parent
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def flows() -> dict:
|
||||
out = subprocess.run(
|
||||
["node", str(HERE / "helper_322_3_flow.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
check=True,
|
||||
)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def _marked(rows: list) -> list:
|
||||
return [row for row in rows if row[1] or row[2] or row[3]]
|
||||
|
||||
|
||||
def test_place(flows: dict) -> None:
|
||||
assert _marked(flows["place"]) == [
|
||||
[93, "옹벽", True, []],
|
||||
[101, "옹벽", True, []],
|
||||
[113, "옹벽", True, []],
|
||||
]
|
||||
|
||||
|
||||
def test_place_on_general_station_gets_name_and_shape(flows: dict) -> None:
|
||||
# 219 흠 — 일반 측점 자리(100 · 120)에 새로 놓은 맹암거 · 관 = 그 자리 사본에 이름 칩 · 관 그림
|
||||
assert _marked(flows["gridPlace"]) == [
|
||||
[100, "맹암거", True, []],
|
||||
[120, "파형강관 D800", True, ["culvert"]],
|
||||
]
|
||||
assert len(flows["gridPlace"]) == 11 # 측점 수 그대로(사본으로 갈음)
|
||||
|
||||
|
||||
def test_move_hides_old_and_adds_new(flows: dict) -> None:
|
||||
assert _marked(flows["move"]) == [[75, "파형강관 D1000", True, ["culvert"]]]
|
||||
assert all(row[0] != 61 for row in flows["move"])
|
||||
|
||||
|
||||
def test_same_group_switch_renames(flows: dict) -> None:
|
||||
assert _marked(flows["sameGroup"]) == [
|
||||
[93, "흙막이", True, []],
|
||||
[101, "흙막이", True, []],
|
||||
[113, "흙막이", True, []],
|
||||
]
|
||||
|
||||
|
||||
def test_cross_owner_switch_drops_pipe_drawing(flows: dict) -> None:
|
||||
assert _marked(flows["crossOwner"]) == [[101, "맹암거", True, []]]
|
||||
|
||||
|
||||
def test_pick_follows_moved_pipe(flows: dict) -> None:
|
||||
moved, other = flows["follow"]
|
||||
assert (moved["chainageM"], moved["structureId"]) == (75, "pipe-75.00")
|
||||
assert (other["chainageM"], other["structureId"]) == (101, "w1") # 구조물 고름은 그대로
|
||||
|
||||
|
||||
def test_remove_and_reopen(flows: dict) -> None:
|
||||
assert _marked(flows["remove"]) == []
|
||||
assert [row[0] for row in flows["remove"]] == list(range(0, 201, 20))
|
||||
assert _marked(flows["reopen"]) == [
|
||||
[93, "옹벽", False, []],
|
||||
[101, "옹벽", False, []],
|
||||
[113, "옹벽", False, []],
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""PLAN 322-3 측점 계획 한 벌 — 파이썬 `plan_stations` = TS `planStations`(거울) · 서버 공급자가 같은 목록.
|
||||
|
||||
- 측점 = 표 횡단도(anchor 기준 · span3 시작 · 기준 · 끝 · none 없음) · 카드 이름 = 표 카드 이름.
|
||||
- 같은 m(0.005) 는 하나 — 이름 = 관이 먼저 · 아니면 앞선 것 · 덮는 구조물 id 모두.
|
||||
- 빈칸 경고 = 형상 부품 필수 칸 중 값이 빈 것(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이).
|
||||
- 서버 `resolve_extra_stations` = 계획의 (측점, 이름) — 겹친 자리는 한 번(옛 「마지막 이름」 규칙 걷음).
|
||||
TS 쪽 = `helper_322_3_plan.cjs`(Node).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections import resolve_extra_stations
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_definition
|
||||
from common_util.common_util_drainage_pipes import parse_pipe_points
|
||||
from common_util.common_util_structure_station import plan_stations
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
ROOT = HERE.parent.parent
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
def _s(type_id: str, sid: str, **at) -> dict:
|
||||
placement = "interval" if "start_m" in at else "point"
|
||||
return {
|
||||
"structure_id": sid,
|
||||
"type_id": type_id,
|
||||
"placement": placement,
|
||||
"options": at.pop("options", {}),
|
||||
**at,
|
||||
}
|
||||
|
||||
|
||||
CASES = {
|
||||
"mixed": {
|
||||
"pipes": [
|
||||
{"chainage_m": 95.0, "source": "user"},
|
||||
{"chainage_m": 160.0, "source": "user", "facility": "box_culvert"},
|
||||
{"chainage_m": 230.0, "source": "user", "facility": "ford_pavement"},
|
||||
{
|
||||
"chainage_m": 400.0,
|
||||
"source": "user",
|
||||
"facility": "revetment",
|
||||
"start_m": 395.0,
|
||||
"end_m": 410.0,
|
||||
"options": {"height_m": 2},
|
||||
},
|
||||
],
|
||||
"structures": [
|
||||
_s("cross_drain_exposed", "s1", chainage_m=120.0),
|
||||
_s(
|
||||
"retaining_wall",
|
||||
"s2",
|
||||
start_m=500.0,
|
||||
chainage_m=510.0,
|
||||
end_m=530.0,
|
||||
options={"height_m": 3},
|
||||
),
|
||||
_s("berm", "s3", start_m=530.0, chainage_m=545.0, end_m=560.0),
|
||||
_s("erosion_check", "s4", chainage_m=600.0),
|
||||
_s("pavement_concrete", "s5", start_m=650.0, chainage_m=650.0, end_m=700.0),
|
||||
_s("cross_underdrain", "s6", chainage_m=95.003),
|
||||
],
|
||||
},
|
||||
"pipe_kinds": {
|
||||
"pipes": [
|
||||
{"chainage_m": 10.0, "options": {"pipe_kind": "흄관", "pipe_diameter_mm": "800"}},
|
||||
{"chainage_m": 20.0, "options": {"pipe_diameter_mm": ""}},
|
||||
{"chainage_m": 30.0, "facility": "ford_bridge"},
|
||||
],
|
||||
"structures": [],
|
||||
},
|
||||
"empty": {"pipes": [], "structures": []},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts() -> dict:
|
||||
definition = load_definition()
|
||||
payload = {
|
||||
"definition": definition,
|
||||
"types": with_visible().model_dump()["types"],
|
||||
"cases": CASES,
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / "input.json"
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
out = subprocess.run(
|
||||
["node", str(HERE / "helper_322_3_plan.cjs"), str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
check=True,
|
||||
)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def _py(case: dict) -> list[dict]:
|
||||
pipes = parse_pipe_points(case["pipes"])
|
||||
structures = [StructureInstance.model_validate(s) for s in case["structures"]]
|
||||
return plan_stations(pipes, structures)
|
||||
|
||||
|
||||
def test_mirror_python_equals_ts(ts: dict) -> None:
|
||||
for name, case in CASES.items():
|
||||
assert _py(case) == ts[name], name
|
||||
|
||||
|
||||
def test_plan_rules(ts: dict) -> None:
|
||||
mixed = ts["mixed"]
|
||||
assert [round(s["chainage_m"], 3) for s in mixed] == [
|
||||
95.0,
|
||||
160.0,
|
||||
230.0,
|
||||
395.0,
|
||||
400.0,
|
||||
410.0,
|
||||
500.0,
|
||||
510.0,
|
||||
530.0,
|
||||
545.0,
|
||||
560.0,
|
||||
]
|
||||
first = mixed[0] # 관 95 + 맹암거 95.003 = 한 측점 · 이름 = 관 · 빈칸 = 관경 + 맹암거 높이
|
||||
assert first["owners"] == ["pipe-95.00", "s6"]
|
||||
assert first["title"] == "파형강관 D1000"
|
||||
assert first["blank"] == ["pipe_diameter_mm", "culvert_height_m"]
|
||||
joint = next(s for s in mixed if s["chainage_m"] == 530.0) # 옹벽 끝 = 소단 시작
|
||||
assert joint["owners"] == ["s2", "s3"] and joint["title"] == "옹벽" and joint["role"] == "end"
|
||||
assert all(s["title"] != "노출형 횡단수로" for s in mixed) # ★1 횡단도 none
|
||||
kinds = ts["pipe_kinds"]
|
||||
assert [s["title"] for s in kinds] == ["흄관 D800", "파형강관 D1000", "세월교"]
|
||||
assert kinds[1]["blank"] == ["pipe_diameter_mm"] and kinds[0]["blank"] == []
|
||||
assert ts["empty"] == []
|
||||
|
||||
|
||||
def test_server_supply_is_plan(tmp_path: Path) -> None:
|
||||
case = CASES["mixed"]
|
||||
route = tmp_path / "B05_Profile" / "route"
|
||||
route.mkdir(parents=True)
|
||||
(route / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": case["structures"]}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
extras = resolve_extra_stations(tmp_path, parse_pipe_points(case["pipes"]))
|
||||
plan = _py(case)
|
||||
assert list(extras) == [(s["chainage_m"], s["title"]) for s in plan]
|
||||
assert len({round(c, 3) for c, _ in extras}) == len(extras) # 같은 자리 한 번
|
||||
|
||||
|
||||
def test_stale_marks_compare_position_and_name(tmp_path: Path, monkeypatch) -> None:
|
||||
"""서버 저장 낡음 = 자리 + 이름(종류 · 관 공식 · 정의 판 이름) — 같은 자리라도 이름이 다르면 맞춤."""
|
||||
from B06_Section import B06_Section_Router_Stations as stations
|
||||
|
||||
folder = tmp_path / "B06_Section" / "longitudinal"
|
||||
folder.mkdir(parents=True)
|
||||
rows = [
|
||||
{"chainage_m": 10.0, "kind": "irregular", "structure": "파형강관 D800"},
|
||||
{"chainage_m": 20.0, "kind": "regular"},
|
||||
{"chainage_m": 30.0, "kind": "irregular", "structure": "옹벽"},
|
||||
]
|
||||
(folder / "l.json").write_text(json.dumps({"stations": rows}), encoding="utf-8")
|
||||
planted = [(10.0, "파형강관 D1000"), (30.0, "옹벽")]
|
||||
monkeypatch.setattr(stations, "_planted", lambda *_: planted)
|
||||
assert stations._stale_marks(tmp_path, "") == 2 # 10 m 옛 이름 · 새 이름
|
||||
planted[0] = (10.0, "파형강관 D800")
|
||||
assert stations._stale_marks(tmp_path, "") == 0
|
||||
Reference in New Issue
Block a user