fix(458 B06 노선 바뀜): 주인 측점 거리 상한 0.51 m · 시종점 밖 시설 안 얹음 · 갈아끼움 grid 지움 · 늘 다시 얹기 · 이름 = 계획 제목
- 관 세트 주인 측점 = 0.51 m 안 가장 가까운 측점(TS attachCulvertSets ↔ 파이썬 attach_culvert_specs) · 넘으면 연동 이웃에도 안 얹고 missed - 형상 얹기 한 벌(attachShapes ↔ attach_shapes) — 시종점 측점 자리인데 좌표가 노선 진행 반대쪽 0.51 m 넘게 바깥 = 「노선 밖」 · 안 얹음 · 안 얹은 것 돌려줌(서버 = 로그 경고) - 측점 갈아끼움 — 격자 아닌 자리면 앞 횡단 grid 표시 지움 · 갈아끼움 뒤 가게 초안 없어도 늘 다시 얹음 · 카드 · 종단 측점 이름 = 계획 제목 · 안 얹은 시설 알림(바뀐 때 한 번) - 시험: test_458_swap_attach(재현 A · D) · test_323_4 거울 moved 경우(상한 · 시종점 밖 · 연동 폭) · test_299 같은 키 견본 = 주인 0.51 m 안으로 · 273_2 흉내 돌려줌 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,53 @@ 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>>;
|
||||
warnMisses(projectId, attachShapes(sections, ...inputs));
|
||||
if (swapped) {
|
||||
const [pipes, structures, types] = inputs;
|
||||
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,7 @@
|
||||
* · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가
|
||||
* 그 행을 지우고 기본 설계에 [저장] patch 를 얹음(`sync_cross_section_rows` · `compute_default_designs` · 428) ·
|
||||
* patch 가 그 칸을 모두 실음(`crossPatchesFromCache` · 445 H2).
|
||||
* · 격자 아닌 자리 = 앞 횡단의 `grid` 표시를 지움 · 카드 이름은 형상 다시 얹기가 계획 제목으로(458).
|
||||
* · 빠진 측점 = 목록에서 뺌(세션 고침은 열쇠로 남음 · 노선이 바뀐 때만 「노선 밖 고침」 · 부르는 쪽).
|
||||
* · 옛 상세는 그대로 둠 — 새 객체를 돌려줌(쥔 쪽이 갈아끼움).
|
||||
*
|
||||
@@ -238,13 +239,15 @@ 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)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) — 이 시험 몫 아님
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/* 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);
|
||||
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 {
|
||||
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));
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""458 노선 바뀜 — B06 갈아끼움 + 지금 현황으로 다시 얹기 · 주인 측점 거리 상한 · 시종점 밖.
|
||||
|
||||
못박는 것
|
||||
- 시점 당김(재현 A 꼴): 관 · 세월교 그림 = 제 측점 하나씩(따라간 누가거리) · 격자를 떠난 관 측점 `grid` 지움.
|
||||
- 가게 초안 없는 채(저장분 현황) 얹어도 옛 자리 세월교가 이웃 일반 측점에 안 붙음(상한 0.51 m · 측점 없음).
|
||||
- 시점 밖으로 밀린 관(재현 D 꼴) = 시점 측점에 안 얹고 「노선 밖」.
|
||||
|
||||
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 # 격자 자리 옹벽 측점
|
||||
Reference in New Issue
Block a user