feat(구조물표): 323-4 형상 얹기 한 벌(TS · 파이썬 짝) — 저장분 · 초안 · 미리 뜬 횡단 같은 길(저장 전 그림 = 저장 뒤 그림) · 빈칸 경고 한 꼴(카드 칩 「종류 칸 빈칸」) · 3D 지문 = 그림 자료 통째 · C군 벽 3D 한 번 · 소단 원천 = 표 형상 berm
- 짝 `common_util_structure_attach.ts` attachShapes ↔ `B06_Section_Engine_Attach.py` attach_shapes — 열쇠 걷고 관 세트 → 벽 → 포장 → 맹암거 → 빈칸 경고(측점 계획 자리 · 「옹벽 높이」 · 「배관 관경」 · 「맹암거 암거 높이」 · 「물넘이포장 월류 높이」) - 서버 `_read_section_detail` · 재생성 응답 = attach_saved_shapes 하나 · 화면 `withDraftShapes`(현황 = 초안 ?? 저장분 · 초안 없어지면 저장분으로 한 번) — decorate 관 세트 · withDraftPipeKinds · 맹암거 · 포장 · 벽 블록 걷음 · 구조물 초안 없을 때 미리 뜬 횡단에 포장 · 벽 · 맹암거 빠지던 것 · 반폭 넓힘 뒤 날 상세(초안 빠짐) 고침 - 파이썬 얹기 = 목록 함수(culvert_sets_from · attach_culvert_specs · wall_specs_from · attach_wall_specs · underdrains_from · attach_underdrain_specs) + 옛 파일 함수는 감쌈 - 3D 지문 = SHAPE_KEYS(`common_util_structure_shape_keys.ts`) + 조작값 통째(손 해시 걷음 · 벽 기초 · 단 · 포장 · 맹암거 빠짐) · C군 벽 = 기준 측점과 가장 가까운 측점에서만 시작 ~ 끝 스윕(`wallSweepAt`) - 소단 = 표 형상 berm(BERM_TYPE_ID 걷음 · 화면 isBermType · 서버 catalog_row) - 시험: test_323_4_attach_mirror(42종 + 빈칸 · 서버 = 화면 · 옛 자리 걷힘 · 벽 한 번 · 지문) · 전수 표 ★ 둘(물넘이포장 걸친 격자 100 = 서버와 같음 · STAGE 4) · 옛 도움 파일 = 측점 → 형상 차례 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
* 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash";
|
||||
import { structureHashParts, wallSweepAt } from "./B05_Profile_UI_Corridor_Structures_Hash";
|
||||
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
import {
|
||||
@@ -286,7 +286,10 @@ export function buildCorridorStructures(
|
||||
// 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위
|
||||
// culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다
|
||||
// (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙).
|
||||
// C군 벽 = 덮는 측점 중 한 곳에서만 · 시작 ~ 끝 스윕(323-4 · 측점마다 통째로 겹치던 것)
|
||||
const wallSweep = wallSweepAt(section, crossSections);
|
||||
const revetLayout =
|
||||
wallSweep &&
|
||||
ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) &&
|
||||
builds(section.revetment?.type_id, "wall")
|
||||
? computeRevetmentLayout(section, section.design?.revet_adjust?.own)
|
||||
@@ -364,18 +367,10 @@ export function buildCorridorStructures(
|
||||
});
|
||||
};
|
||||
|
||||
if (revetLayout) {
|
||||
if (revetLayout && wallSweep) {
|
||||
// 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다.
|
||||
for (const tier of revetLayout.tiers) {
|
||||
pushSwept(
|
||||
"revet",
|
||||
tier.polygon,
|
||||
revetLayout.span.beforeM,
|
||||
revetLayout.span.afterM,
|
||||
null,
|
||||
"own",
|
||||
);
|
||||
}
|
||||
for (const tier of revetLayout.tiers)
|
||||
pushSwept("revet", tier.polygon, wallSweep.beforeM, wallSweep.afterM, null, "own");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,141 +1,79 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Structures_Hash.ts
|
||||
* 저장 코리도 만료 판정용 **구조물 해시 요약**.
|
||||
* 저장 코리도 만료 판정용 **구조물 지문** · C군 벽 3D 한 번(323-4).
|
||||
*
|
||||
* `B05_Profile_UI_Corridor_Structures` 에서 떼어냈다(700줄 제한, 2026-09-02).
|
||||
* 값이 바뀌면 저장본이 만료되도록 **구조물에 영향을 주는 값만** 이어 붙인다 —
|
||||
* 빠뜨린 값은 곧 "고쳐도 3D가 안 바뀌는" 버그가 되므로 항목 추가에 주의할 것.
|
||||
* 원래 파일이 그대로 다시 내보내므로 기존 import 경로는 유효하다.
|
||||
* 지문 = 그 측점 그림 자료(형상 얹기 한 벌 `SHAPE_KEYS` · 카드 · 상세 창 · B07 · 3D 가 같은 목록) + 형상을
|
||||
* 바꾸는 조작값 통째를 키 차례 고정 글로 — 손으로 고른 칸 목록(빠뜨린 칸 = 「고쳐도 3D 가 안 바뀜」)을 걷음.
|
||||
* 원래 파일(`B05_Profile_UI_Corridor_Structures`)이 그대로 다시 내보내므로 기존 import 경로는 유효하다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
ChuteSpec,
|
||||
CrossSection,
|
||||
CulvertSideSpec,
|
||||
} from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { SHAPE_KEYS } from "../common_util/common_util_structure_shape_keys";
|
||||
|
||||
/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */
|
||||
function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> {
|
||||
if (!chute) return [];
|
||||
// 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로
|
||||
const base = ["ch", chute.structure, chute.thickness_m];
|
||||
return chute.length_m ? [...base, chute.length_m] : base;
|
||||
}
|
||||
/** 형상을 바꾸는 조작값(정본 `design` 칸) — 조정창 · 다단 · 연동 · 유입 구조. */
|
||||
const ADJUST_KEYS = [
|
||||
"revet_adjust",
|
||||
"box_adjust",
|
||||
"ford_adjust",
|
||||
"basin_adjust",
|
||||
"extra_wall_counts",
|
||||
"extra_spans",
|
||||
"revet_follow_grade",
|
||||
"revet_link_detached",
|
||||
"inlet_structure",
|
||||
] as const;
|
||||
|
||||
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
|
||||
export function structureHashParts(section: CrossSection): Array<string | number> {
|
||||
// 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 —
|
||||
// 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28).
|
||||
const extras: Array<string | number> = [];
|
||||
const fordPave = section.ford_pavement;
|
||||
if (fordPave) {
|
||||
extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? "");
|
||||
extras.push(...chuteParts(fordPave.chute));
|
||||
}
|
||||
const ownRevet = section.revetment;
|
||||
if (ownRevet) {
|
||||
extras.push(
|
||||
"rv",
|
||||
ownRevet.start_m,
|
||||
ownRevet.end_m,
|
||||
ownRevet.anchor_m,
|
||||
ownRevet.height_m ?? "",
|
||||
ownRevet.side ?? "",
|
||||
ownRevet.form ?? "",
|
||||
ownRevet.tiers ?? "",
|
||||
ownRevet.lift_m ?? "",
|
||||
ownRevet.shift_m ?? "",
|
||||
// 조정창 조작값이 바뀌면 저장 코리도를 만료시킨다.
|
||||
JSON.stringify(section.design?.revet_adjust?.own ?? ""),
|
||||
);
|
||||
}
|
||||
const box = section.box;
|
||||
// 세월교 · 박스에 흙막이(`culvert`)가 함께 서면 그 값도 이어 붙임(73-4) — 일찍 돌려주지 않음.
|
||||
if (box) {
|
||||
extras.push(
|
||||
"bx",
|
||||
box.inner_width_m,
|
||||
box.inner_height_m,
|
||||
box.span_m,
|
||||
box.cover_m,
|
||||
box.wing_in.slab_extend_m,
|
||||
box.wing_in.height_m ?? "",
|
||||
box.wing_out.slab_extend_m,
|
||||
box.wing_out.height_m ?? "",
|
||||
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
|
||||
...(["left", "right"] as const).map((side) => {
|
||||
const value = section.design?.box_adjust?.[side];
|
||||
return value ? `${side}:${value.lengthM},${value.riseM}` : "";
|
||||
}),
|
||||
...chuteParts(box.chute),
|
||||
);
|
||||
}
|
||||
const ford = section.ford;
|
||||
if (ford) {
|
||||
const adjust = section.design?.ford_adjust;
|
||||
const wall = (role: "inlet" | "outlet"): string => {
|
||||
const value = adjust?.[role];
|
||||
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
|
||||
};
|
||||
extras.push(`dr:${adjust?.dropM ?? ""}`);
|
||||
extras.push(
|
||||
"fd",
|
||||
ford.diameter_m,
|
||||
ford.pipe_count,
|
||||
ford.span_m,
|
||||
ford.wing_in.slab_extend_m,
|
||||
ford.wing_out.slab_extend_m,
|
||||
wall("inlet"),
|
||||
wall("outlet"),
|
||||
...chuteParts(ford.chute),
|
||||
);
|
||||
}
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return extras;
|
||||
const side = (spec: CulvertSideSpec): string =>
|
||||
[
|
||||
spec.structure,
|
||||
spec.revet_form ?? "",
|
||||
spec.revet_height_m ?? "",
|
||||
spec.revet_length_m ?? "",
|
||||
spec.revet_before_m ?? "",
|
||||
spec.revet_after_m ?? "",
|
||||
spec.basin_length_m ?? "",
|
||||
spec.basin_before_m ?? "",
|
||||
spec.basin_after_m ?? "",
|
||||
spec.basin_height_m ?? "",
|
||||
].join(",");
|
||||
const basin = section.design?.basin_adjust;
|
||||
const revet = section.design?.revet_adjust;
|
||||
const counts = section.design?.extra_wall_counts;
|
||||
/** 키 차례를 고정한 JSON — 같은 값이면 같은 글. */
|
||||
const stable = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, item: unknown) =>
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? Object.fromEntries(
|
||||
Object.keys(item)
|
||||
.sort()
|
||||
.map((key) => [key, (item as Record<string, unknown>)[key]]),
|
||||
)
|
||||
: item,
|
||||
);
|
||||
|
||||
/** 해시 입력 — 구조물이 없는 측점은 빈 목록(저장본 지문 그대로). */
|
||||
export function structureHashParts(section: CrossSection): string[] {
|
||||
const record = section as unknown as Record<string, unknown>;
|
||||
const shapes = SHAPE_KEYS.filter((key) => record[key] != null);
|
||||
if (!shapes.length) return [];
|
||||
const design = (section.design ?? {}) as unknown as Record<string, unknown>;
|
||||
return [
|
||||
...extras,
|
||||
"cv",
|
||||
culvert.diameter_m,
|
||||
culvert.pipe_kind ?? "",
|
||||
side(culvert.inlet),
|
||||
side(culvert.outlet),
|
||||
section.design?.inlet_structure ?? "",
|
||||
basin ? `${basin.innerWidthM},${basin.innerHeightM},${basin.lateralM},${basin.slopeM}` : "",
|
||||
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
|
||||
revet
|
||||
? Object.keys(revet)
|
||||
.sort()
|
||||
.map((role) => {
|
||||
const value = revet[role];
|
||||
return `${role}:${value.x},${value.d ?? ""},${value.h ?? ""},${value.m ?? ""}`;
|
||||
})
|
||||
.join("|")
|
||||
: "",
|
||||
counts ? `${counts.outlet},${counts.basin}` : "",
|
||||
section.design?.revet_follow_grade === false ? "flatZ" : "",
|
||||
// 다단 단별 구간값·연동 해제도 형상을 바꾼다 — 빠지면 저장 코리도가 안 만료돼
|
||||
// 값만 바뀌고 3D는 옛 모양 그대로다(2026-08-30).
|
||||
Object.entries(section.design?.extra_spans ?? {})
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`)
|
||||
.join("|"),
|
||||
section.design?.revet_link_detached === true ? "detached" : "",
|
||||
...chuteParts(culvert.chute),
|
||||
stable({
|
||||
...Object.fromEntries(shapes.map((key) => [key, record[key]])),
|
||||
adjust: Object.fromEntries(ADJUST_KEYS.map((key) => [key, design[key] ?? null])),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* C군 벽 3D 한 번 — 그 벽을 얹은 측점 중 기준 측점과 가장 가까운 것(목록 앞쪽 먼저)만 스윕 ·
|
||||
* 스윕 = 시작 ~ 끝(그 측점 기준 전 · 후). 다른 측점은 null(덮는 측점마다 통째로 겹치던 것).
|
||||
*/
|
||||
export function wallSweepAt(
|
||||
section: CrossSection,
|
||||
sections: readonly CrossSection[],
|
||||
): { beforeM: number; afterM: number } | null {
|
||||
const wall = section.revetment;
|
||||
if (!wall) return null;
|
||||
const same = (other: CrossSection): boolean =>
|
||||
!!other.revetment &&
|
||||
(wall.structure_id
|
||||
? other.revetment.structure_id === wall.structure_id
|
||||
: other.revetment.start_m === wall.start_m && other.revetment.end_m === wall.end_m);
|
||||
const gap = (other: CrossSection): number => Math.abs(other.chainage_m - wall.anchor_m);
|
||||
const owner = sections
|
||||
.filter(same)
|
||||
.reduce<CrossSection | null>(
|
||||
(best, other) => (!best || gap(other) < gap(best) ? other : best),
|
||||
null,
|
||||
);
|
||||
if (owner !== section) return null;
|
||||
return {
|
||||
beforeM: Math.max(section.chainage_m - wall.start_m, 0),
|
||||
afterM: Math.max(wall.end_m - section.chainage_m, 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,6 +339,8 @@ export interface CrossSection extends SectionStation {
|
||||
pavement?: PavementEntry[];
|
||||
/** 맹암거 — 기준 측점(288-1). 높이 · 경사 · 측구 없음 · 노면 기울임 · 돌 무늬 형상. */
|
||||
underdrain?: UnderdrainSpec;
|
||||
/** 빈칸 경고(323-4 · 읽기 칸 · 저장 안 함) — 「종류 칸」(형상 필수 칸이 빈 관 · 구조물 · 측점 계획 자리). */
|
||||
blank?: string[];
|
||||
}
|
||||
|
||||
export interface SectionDetailResponse {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""형상 얹기 한 벌(323-4) — 측점(횡단 dict) + 현황(관 · 구조물) + 구조물 표 → 그 측점 그림 자료.
|
||||
|
||||
구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 차례대로 다시 얹음 — 관 세트 → 벽 → 포장 → 맹암거 →
|
||||
빈칸 경고. 고르기 = 표 형상(322-1). 서버 상세 읽기(`_read_section_detail`) · 재생성 응답이 이것 하나를 씀.
|
||||
TS 짝 = `common_util/common_util_structure_attach.ts` `attachShapes`(화면 = 초안 ?? 저장분 현황) ·
|
||||
거울 시험 = `resources/tester/test_323_4_attach_mirror.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from B06_Section.B06_Section_Engine_Culvert import (
|
||||
attach_culvert_specs,
|
||||
culvert_sets_from,
|
||||
pipe_points_file,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_specs, wall_specs_from
|
||||
from B06_Section.B06_Section_Engine_Tilt import attach_underdrain_specs, underdrains_from
|
||||
from common_util.common_util_drainage_pipes import read_pipe_points_file
|
||||
from common_util.common_util_structure_catalog import catalog_row, field_name, type_ids_with
|
||||
from common_util.common_util_structure_pavement import (
|
||||
attach_pavement_specs,
|
||||
pavement_specs_from,
|
||||
)
|
||||
from common_util.common_util_structure_station import (
|
||||
SAME_STATION_M,
|
||||
blank_keys,
|
||||
plan_stations,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 측점 그림 자료 열쇠 — TS `common_util_structure_shape_keys.SHAPE_KEYS` 짝.
|
||||
SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain")
|
||||
|
||||
|
||||
def _blank_labels(type_id: str, options: dict | None, types: dict[str, Any]) -> list[str]:
|
||||
"""빈 필수 칸 → 「종류 이름 칸 이름」(TS `blankLabels` 짝)."""
|
||||
name = (catalog_row(type_id) or {}).get("name") or type_id
|
||||
ordered = [types[type_id]] if type_id in types else []
|
||||
ordered += list(types.values())
|
||||
out = []
|
||||
for key in blank_keys(type_id, options):
|
||||
label = next(
|
||||
(o.label for t in ordered for o in t.options if o.key == key and o.label), None
|
||||
)
|
||||
out.append(f"{name} {label or field_name(key) or key}")
|
||||
return out
|
||||
|
||||
|
||||
def _attach_blanks(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None:
|
||||
"""빈칸 경고 — 측점 계획 자리마다 덮는 관 · 구조물의 빈 필수 칸(TS `attachBlanks` 짝)."""
|
||||
types = structure_type_map()
|
||||
owners: dict[str, tuple[str, dict | None]] = {}
|
||||
for pipe in pipes:
|
||||
owner = getattr(pipe, "id", None) or f"pipe-{float(pipe.chainage_m):.2f}"
|
||||
owners[owner] = (pipe.facility or "pipe", pipe.options)
|
||||
for item in structures:
|
||||
if item.structure_id:
|
||||
owners[item.structure_id] = (item.type_id, item.options)
|
||||
for station in plan_stations(pipes, structures):
|
||||
if not station["blank"]:
|
||||
continue
|
||||
labels: list[str] = []
|
||||
for owner in station["owners"]:
|
||||
hit = owners.get(owner)
|
||||
for label in _blank_labels(hit[0], hit[1], types) if hit else []:
|
||||
if label not in labels:
|
||||
labels.append(label)
|
||||
for section in sections:
|
||||
at = section.get("chainage_m")
|
||||
if isinstance(at, (int, float)) and abs(at - station["chainage_m"]) < SAME_STATION_M:
|
||||
section["blank"] = labels
|
||||
|
||||
|
||||
def attach_shapes(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None:
|
||||
"""측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침)."""
|
||||
for section in sections:
|
||||
for key in (*SHAPE_KEYS, "blank"):
|
||||
section.pop(key, None)
|
||||
attach_culvert_specs(sections, culvert_sets_from(pipes))
|
||||
# 관 세트가 선 측점은 벽을 안 얹음(`attach_wall_specs`) — 차례 = 화면과 같음
|
||||
attach_wall_specs(sections, wall_specs_from(structures))
|
||||
attach_pavement_specs(
|
||||
sections,
|
||||
pavement_specs_from([s.model_dump() for s in structures], type_ids_with(shape="pavement")),
|
||||
)
|
||||
attach_underdrain_specs(sections, underdrains_from(structures))
|
||||
_attach_blanks(sections, pipes, structures)
|
||||
|
||||
|
||||
def attach_saved_shapes(project_root: Path, sections: list[dict[str, Any]]) -> None:
|
||||
"""저장분 현황(관 지점 · 구조물 정본)으로 얹음 — 구조물 정본을 못 읽으면 관만(횡단 조회는 이어 감)."""
|
||||
pipes = read_pipe_points_file(pipe_points_file(project_root))
|
||||
try:
|
||||
structures = load_structures(str(project_root))[1]
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
|
||||
logger.exception("B06 구조물 정본을 읽지 못했습니다 (관만 얹음)")
|
||||
structures = []
|
||||
attach_shapes(sections, pipes, structures)
|
||||
@@ -604,8 +604,13 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("B06 배수관 세트: 관 지점 파일을 읽지 못했습니다 (%s)", path)
|
||||
return {}
|
||||
return culvert_sets_from(parse_pipe_points(document.get("points")))
|
||||
|
||||
|
||||
def culvert_sets_from(points: list[Any]) -> dict[float, dict[str, Any]]:
|
||||
"""관 지점 목록 → 누가거리(소수 2자리)별 세트 제원(TS `buildCulvertSets` 짝 · 형상 얹기 한 벌 323-4)."""
|
||||
sets: dict[float, dict[str, Any]] = {}
|
||||
for point in parse_pipe_points(document.get("points")):
|
||||
for point in points:
|
||||
# 그릴 세트 = 구조물 표 형상(322-1 · TS `buildCulvertSets` 짝)
|
||||
shape = (catalog_row(point.facility) or {}).get("shape")
|
||||
if shape == "pipe_set":
|
||||
@@ -631,7 +636,13 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
|
||||
|
||||
def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""배관이 놓인 측점의 횡단 dict에 `culvert` 키를 얹는다. 얹은 개수를 돌려준다."""
|
||||
sets = load_culvert_sets(project_root)
|
||||
return attach_culvert_specs(cross_sections, load_culvert_sets(project_root))
|
||||
|
||||
|
||||
def attach_culvert_specs(
|
||||
cross_sections: list[dict[str, Any]], sets: dict[float, dict[str, Any]]
|
||||
) -> int:
|
||||
"""세트를 측점에 얹는다(TS `attachCulvertSets` 짝). 얹은 개수를 돌려준다."""
|
||||
if not sets:
|
||||
return 0
|
||||
attached = 0
|
||||
|
||||
@@ -66,59 +66,60 @@ def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다).
|
||||
BERM_TYPE_ID = "berm"
|
||||
|
||||
|
||||
def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""구조물 정본에서 벽(표 형상 wall · 322-1) 목록을 읽는다. 실패하면 빈 목록.
|
||||
|
||||
소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 제원 자리에 안 얹음(계획서 3-9).
|
||||
"""
|
||||
try:
|
||||
types = structure_type_map()
|
||||
found: list[dict[str, Any]] = []
|
||||
for structure in load_structures(str(project_root))[1]:
|
||||
definition = types.get(structure.type_id)
|
||||
if definition is None or (catalog_row(structure.type_id) or {}).get("shape") != "wall":
|
||||
continue
|
||||
start, end = structure.start_m, structure.end_m
|
||||
if start is None or end is None:
|
||||
continue
|
||||
options = structure.options or {}
|
||||
extras = tier_extras(structure.type_id, options)
|
||||
found.append(
|
||||
{
|
||||
"structure_id": structure.structure_id,
|
||||
"type_id": structure.type_id,
|
||||
"name": definition.name,
|
||||
"start_m": float(min(start, end)),
|
||||
"end_m": float(max(start, end)),
|
||||
"anchor_m": float(structure.anchor_m()),
|
||||
"form": options.get("form") or _wall_form(structure.type_id),
|
||||
"height_m": options.get("height_m"),
|
||||
# C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로
|
||||
# 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다.
|
||||
"side": options.get("side"),
|
||||
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다.
|
||||
# 비어 있으면 화면이 터파기를 안 그린다(근거 없음).
|
||||
"foundation": options.get("foundation"),
|
||||
# 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
|
||||
"tiers": len(extras) + 1 if extras else options.get("tiers"),
|
||||
"extras": extras,
|
||||
"lift_m": options.get("lift_m"),
|
||||
"shift_m": options.get("shift_m"),
|
||||
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
|
||||
"face_role": options.get("face_role"),
|
||||
"face_slope_ratio": options.get("face_slope_ratio"),
|
||||
}
|
||||
)
|
||||
return found
|
||||
return wall_specs_from(load_structures(str(project_root))[1])
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
|
||||
logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)")
|
||||
return []
|
||||
|
||||
|
||||
def wall_specs_from(structures: list[Any]) -> list[dict[str, Any]]:
|
||||
"""구조물 목록 → 벽 제원(TS `wallSpecsFrom` 짝 · 형상 얹기 한 벌 323-4)."""
|
||||
types = structure_type_map()
|
||||
found: list[dict[str, Any]] = []
|
||||
for structure in structures:
|
||||
definition = types.get(structure.type_id)
|
||||
if definition is None or (catalog_row(structure.type_id) or {}).get("shape") != "wall":
|
||||
continue
|
||||
start, end = structure.start_m, structure.end_m
|
||||
if start is None or end is None:
|
||||
continue
|
||||
options = structure.options or {}
|
||||
extras = tier_extras(structure.type_id, options)
|
||||
found.append(
|
||||
{
|
||||
"structure_id": structure.structure_id,
|
||||
"type_id": structure.type_id,
|
||||
"name": definition.name,
|
||||
"start_m": float(min(start, end)),
|
||||
"end_m": float(max(start, end)),
|
||||
"anchor_m": float(structure.anchor_m()),
|
||||
"form": options.get("form") or _wall_form(structure.type_id),
|
||||
"height_m": options.get("height_m"),
|
||||
# C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로
|
||||
# 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다.
|
||||
"side": options.get("side"),
|
||||
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다.
|
||||
# 비어 있으면 화면이 터파기를 안 그린다(근거 없음).
|
||||
"foundation": options.get("foundation"),
|
||||
# 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
|
||||
"tiers": len(extras) + 1 if extras else options.get("tiers"),
|
||||
"extras": extras,
|
||||
"lift_m": options.get("lift_m"),
|
||||
"shift_m": options.get("shift_m"),
|
||||
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
|
||||
"face_role": options.get("face_role"),
|
||||
"face_slope_ratio": options.get("face_slope_ratio"),
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, float]]] | None:
|
||||
"""구조물 정본의 소단 구간 `[(시작, 끝, 제원)]` — [저장] 서버 다시 셈의 소단 원천(194-1).
|
||||
|
||||
@@ -142,7 +143,7 @@ def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, fl
|
||||
try:
|
||||
spans: list[tuple[float, float, dict[str, float]]] = []
|
||||
for item in load_structures(str(project_root))[1]:
|
||||
if item.type_id != BERM_TYPE_ID:
|
||||
if (catalog_row(item.type_id) or {}).get("shape") != "berm": # 표 형상(323-4)
|
||||
continue
|
||||
anchor = item.chainage_m if item.chainage_m is not None else item.start_m
|
||||
start = item.start_m if item.start_m is not None else anchor
|
||||
@@ -190,7 +191,14 @@ def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, An
|
||||
관 유입·유출 벽과 구조물 벽이 한 자리에 겹치면 어느 쪽 그림인지 읽히지 않는다.
|
||||
한 측점에 여러 개가 겹치면 먼저 시작한 것을 쓴다(겹침 정리는 사용자 몫).
|
||||
"""
|
||||
walls = load_wall_structures(project_root)
|
||||
attached = attach_wall_specs(cross_sections, load_wall_structures(project_root))
|
||||
if attached:
|
||||
logger.info("B06 구조물 벽 %d개 측점에 얹음", attached)
|
||||
return attached
|
||||
|
||||
|
||||
def attach_wall_specs(cross_sections: list[dict[str, Any]], walls: list[dict[str, Any]]) -> int:
|
||||
"""구간 안 측점에 벽 제원을 얹는다(TS `attachWallSpecs` 짝). 얹은 개수를 돌려준다."""
|
||||
if not walls:
|
||||
return 0
|
||||
attached = 0
|
||||
@@ -209,6 +217,4 @@ def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, An
|
||||
section["revetment"] = spec
|
||||
attached += 1
|
||||
break
|
||||
if attached:
|
||||
logger.info("B06 구조물 벽 %d개 측점에 얹음 (구조물 %d건)", attached, len(walls))
|
||||
return attached
|
||||
|
||||
@@ -42,6 +42,11 @@ def load_underdrains(project_root: Path) -> list[dict[str, Any]]:
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
|
||||
logger.exception("B06 맹암거를 읽지 못했습니다 (없음으로 본다)")
|
||||
return []
|
||||
return underdrains_from(structures)
|
||||
|
||||
|
||||
def underdrains_from(structures: list[Any]) -> list[dict[str, Any]]:
|
||||
"""구조물 목록의 맹암거 제원(TS `underdrainSpecsFrom` 짝 · 형상 얹기 한 벌 323-4)."""
|
||||
found: list[dict[str, Any]] = []
|
||||
for structure in structures:
|
||||
if (catalog_row(structure.type_id) or {}).get("shape") != "underdrain":
|
||||
@@ -85,7 +90,13 @@ def tilt_args(chainage_m: float, ranges: list[TiltRange]) -> dict[str, Any]:
|
||||
|
||||
def attach_underdrains(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""맹암거 기준 측점의 횡단 dict 에 `underdrain` 을 얹는다. 얹은 개수를 돌려준다."""
|
||||
drains = load_underdrains(project_root)
|
||||
return attach_underdrain_specs(cross_sections, load_underdrains(project_root))
|
||||
|
||||
|
||||
def attach_underdrain_specs(
|
||||
cross_sections: list[dict[str, Any]], drains: list[dict[str, Any]]
|
||||
) -> int:
|
||||
"""기준 측점에 맹암거 제원을 얹는다(TS `attachUnderdrainSpecs` 짝). 얹은 개수를 돌려준다."""
|
||||
attached = 0
|
||||
for section in cross_sections:
|
||||
chainage = _number(section.get("chainage_m"))
|
||||
|
||||
@@ -19,14 +19,13 @@ from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
run_section_generation,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets
|
||||
from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes
|
||||
from B06_Section.B06_Section_Engine_Design import (
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures
|
||||
from B06_Section.B06_Section_Engine_Tilt import attach_underdrains, tilt_args, tilt_ranges
|
||||
from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
count_cross_sections,
|
||||
create_longitudinal_section,
|
||||
@@ -87,7 +86,6 @@ from B06_Section.B06_Section_Schema import (
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_structure_pavement import attach_pavement_structures
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
@@ -278,15 +276,9 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
isinstance(section, dict) for section in cross_sections
|
||||
):
|
||||
raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.")
|
||||
# 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다.
|
||||
# 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points).
|
||||
attach_culvert_sets(root, cross_sections)
|
||||
# 측점 그림 자료(관 세트 · 벽 · 포장 · 맹암거 · 빈칸 경고) = 형상 얹기 한 벌(323-4 · 화면 짝 attachShapes)
|
||||
attach_saved_shapes(root, cross_sections)
|
||||
relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4)
|
||||
# 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 —
|
||||
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
|
||||
attach_wall_structures(root, cross_sections)
|
||||
attach_pavement_structures(root, cross_sections) # 노면공 형상(88)
|
||||
attach_underdrains(root, cross_sections) # 맹암거 형상(288-1)
|
||||
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
||||
|
||||
|
||||
@@ -446,11 +438,8 @@ async def regenerate_sections(
|
||||
await connection.rollback()
|
||||
raise
|
||||
result = sections["result"]
|
||||
# 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다.
|
||||
attach_culvert_sets(project_root, result["cross_sections"])
|
||||
attach_wall_structures(project_root, result["cross_sections"])
|
||||
attach_pavement_structures(project_root, result["cross_sections"])
|
||||
attach_underdrains(project_root, result["cross_sections"])
|
||||
# 재생성 응답도 상세 조회와 같은 형상 얹기 한 벌(323-4) — 화면이 어긋나지 않게.
|
||||
attach_saved_shapes(project_root, result["cross_sections"])
|
||||
return SectionDetailResponse(
|
||||
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음.
|
||||
*
|
||||
* 심는 자리 · 카드 이름 = 측점 계획 한 벌(`planStations` · 서버 `resolve_extra_stations` 짝 · 322-3) ·
|
||||
* 관 그림 = 짝 산식 `common_util_culvert_sets`. 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도
|
||||
* 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3).
|
||||
* 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` → `attachShapes` · 서버 `attach_shapes` 짝 · 323-4). 일반 측점
|
||||
* 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
@@ -21,11 +21,8 @@ import {
|
||||
type StructureType,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import { registryDefaults } from "@util/common_util_culvert_sets";
|
||||
import { attachShapes } from "@util/common_util_structure_attach";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
planStations,
|
||||
@@ -119,14 +116,8 @@ function bareOf(section: CrossSection): CrossSection {
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */
|
||||
function decorate(
|
||||
fresh: CrossSection[],
|
||||
pipes: Pipes,
|
||||
planned: readonly PlannedStation[],
|
||||
types: StructureType[],
|
||||
): void {
|
||||
const registry = registryDefaults(types);
|
||||
/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름(그림 자료는 `withDraftShapes` 가 모든 측점에 한 번). */
|
||||
function decorate(fresh: CrossSection[], planned: readonly PlannedStation[]): void {
|
||||
for (const section of fresh) {
|
||||
section.preview = true;
|
||||
// 카드 이름 = 측점 계획 이름(322-3 · 같은 m 는 관이 먼저)
|
||||
@@ -134,18 +125,41 @@ function decorate(
|
||||
if (hit) section.structure = hit.title;
|
||||
else delete section.structure; // 구조물이 다 빠진 일반 측점 자리(217)
|
||||
}
|
||||
const own = pipes.filter((pipe) =>
|
||||
fresh.some((section) => near(section.chainage_m, pipe.chainage_m)),
|
||||
);
|
||||
// 미리 뜬 횡단은 한 벌을 기억해 다시 씀 — 얹기 전에 관 계열 제원을 비움(앞 종류 제원이 남아
|
||||
// 겹치던 것 · 서버가 얹어 온 저장분 종류도 · 열쇠 = `Section_Store` FACILITY_KEYS 와 같음 · 317-3)
|
||||
for (const section of fresh as unknown as Array<Record<string, unknown>>) {
|
||||
for (const key of ["culvert", "ford", "box", "ford_pavement"]) delete section[key];
|
||||
}
|
||||
attachCulvertSets(
|
||||
fresh as unknown as Array<Record<string, unknown>>,
|
||||
buildCulvertSets(own, registry),
|
||||
);
|
||||
}
|
||||
|
||||
/** 현황 = 종류마다 초안 ?? 저장분 + 종류 표 — 측점 계획 · 형상 얹기가 같은 것(못 받으면 null). */
|
||||
function statusOf(
|
||||
projectId: string,
|
||||
pipesDraft: Pipes | null,
|
||||
structuresDraft: StructureInstance[] | null,
|
||||
): Promise<[Pipes, StructureInstance[], StructureType[]] | null> {
|
||||
return Promise.all([
|
||||
pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes),
|
||||
structuresDraft ?? savedOf(projectId).then(([, structures]) => structures),
|
||||
fetchStructureTypes(true),
|
||||
]).catch(() => null);
|
||||
}
|
||||
|
||||
/** 초안으로 그림 자료를 얹은 상세 — 초안이 없어지면 저장분으로 한 번 다시 얹음. */
|
||||
const drafted = new WeakSet<SectionDetailResponse>();
|
||||
|
||||
/**
|
||||
* 모든 측점 그림 자료 = 형상 얹기 한 벌(323-4 · 서버 `attach_shapes` 짝) — 현황 = 초안 ?? 저장분 ·
|
||||
* 저장 전 그림 = 저장 뒤 그림. 초안이 없고 앞서 초안으로 얹은 적도 없으면 서버가 얹은 그대로.
|
||||
*/
|
||||
export async function withDraftShapes(
|
||||
detail: SectionDetailResponse,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
const pipesDraft = readPendingPipes(projectId);
|
||||
const structuresDraft = readPendingStructures(projectId);
|
||||
const drafts = !!(pipesDraft || structuresDraft);
|
||||
if (!drafts && !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);
|
||||
else drafted.delete(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,11 +184,7 @@ export async function withDraftStations(
|
||||
let stale: CrossSection[] = [];
|
||||
let fresh: CrossSection[] = [];
|
||||
if (pipesDraft || structuresDraft) {
|
||||
const inputs = await Promise.all([
|
||||
pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes),
|
||||
structuresDraft ?? savedOf(projectId).then(([, structures]) => structures),
|
||||
fetchStructureTypes(true),
|
||||
]).catch(() => null);
|
||||
const inputs = await statusOf(projectId, pipesDraft, structuresDraft);
|
||||
if (inputs) {
|
||||
const [pipes, structures, types] = inputs;
|
||||
const planned = planStations(pipes, structures, types, registryDefaults(types));
|
||||
@@ -226,7 +236,7 @@ export async function withDraftStations(
|
||||
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, planned, types);
|
||||
if (fresh.length || bare.length) decorate([...fresh, ...bare], planned);
|
||||
}
|
||||
}
|
||||
if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음
|
||||
|
||||
@@ -18,35 +18,13 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import {
|
||||
attachWallSpecs,
|
||||
wallSpecsFrom,
|
||||
type WallStructureInput,
|
||||
} from "@util/common_util_structure_walls";
|
||||
import {
|
||||
attachPavementSpecs,
|
||||
pavementSpecsFrom,
|
||||
type PavementStructureInput,
|
||||
} from "@util/common_util_structure_pavement";
|
||||
import { setGeneralStations } from "../common_util/common_util_structure_station";
|
||||
import { cardTitle, catalogRow } from "../common_util/common_util_structure_catalog";
|
||||
import {
|
||||
attachUnderdrainSpecs,
|
||||
underdrainSpecsFrom,
|
||||
type UnderdrainInput,
|
||||
} from "../common_util/common_util_structure_underdrain";
|
||||
import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts";
|
||||
forgetDraftStations,
|
||||
withDraftShapes,
|
||||
withDraftStations,
|
||||
} from "./B06_Section_Section_Drafts";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
|
||||
@@ -61,12 +39,11 @@ function keyOf(projectId: string, routeId: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 아직 저장하지 않은 구조물(C군 벽)을 상세에 얹는다 — **여기가 유일한 자리**다.
|
||||
* 초안(관 · 구조물)을 상세에 얹는다 — **여기가 유일한 자리**다.
|
||||
*
|
||||
* 서버는 저장분만 얹어 준다(`attach_wall_structures`). 초안이 있으면 그것이 화면의
|
||||
* 정본이므로, 캐시·세션·새 요청 어느 길로 들어오든 이 함수를 지나 같은 상태가 된다.
|
||||
* 페이지마다 따로 얹으면 새로고침 뒤 세션 스냅샷이 저장분 기준으로 되돌아간다
|
||||
* (2026-09-06 실측). 산식은 서버와 짝(`common_util_structure_walls`).
|
||||
* 서버는 저장분만 얹어 준다. 초안이 있으면 그것이 화면의 정본이므로, 캐시·세션·새 요청 어느 길로
|
||||
* 들어오든 이 함수를 지나 같은 상태가 된다(2026-09-06 실측). 측점 = 측점 계획 한 벌(`withDraftStations`) ·
|
||||
* 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` · 서버 `attach_shapes` 짝 · 323-4).
|
||||
*/
|
||||
async function withDraftWalls(
|
||||
detail: SectionDetailResponse,
|
||||
@@ -75,109 +52,10 @@ async function withDraftWalls(
|
||||
): Promise<SectionDetailResponse> {
|
||||
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
|
||||
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
|
||||
const drafts = readPendingStructures(projectId);
|
||||
// 빈 목록 = 사용자가 다 지움 → 저장분 벽 · 노면 · 맹암거도 걷음(목록 `??` 규칙과 같음 · 312).
|
||||
// 옛 「놓아도 안 보임」(09-09)은 옛 배열 참조가 `[]` 를 알리던 것 — 지금 `setStructures` 는
|
||||
// 배열 안을 채우고(갈아끼우지 않음) 알리지 않음 · 시험 `test_312_empty_pipes` 가 지킴.
|
||||
await withDraftPipeKinds(detail, projectId);
|
||||
if (!drafts) return withStructureAreas(detail);
|
||||
// 맹암거(288-1) — 초안 기준 측점으로 제원을 다시 얹음(짝 `common_util_structure_underdrain`)
|
||||
attachUnderdrainSpecs(
|
||||
detail.cross_sections as unknown as Array<Record<string, unknown>>,
|
||||
underdrainSpecsFrom(drafts as unknown as UnderdrainInput[]),
|
||||
);
|
||||
// 표 형상(322-1) — 「사용 중」 거르기 없음(꺼진 종류도 그 그림대로 · 쇄석 포장층)
|
||||
const types = await fetchStructureTypes(true).catch(() => []);
|
||||
const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape);
|
||||
// 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`)
|
||||
const paveTypes = new Set(shaped("pavement").map((type) => type.type_id));
|
||||
if (paveTypes.size) {
|
||||
for (const section of detail.cross_sections) delete section.pavement;
|
||||
attachPavementSpecs(
|
||||
detail.cross_sections as unknown as Array<Record<string, unknown>>,
|
||||
pavementSpecsFrom(drafts as unknown as PavementStructureInput[], paveTypes),
|
||||
);
|
||||
}
|
||||
// 벽 = 표 형상 wall(소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 자리에 안 얹음 · 계획서 3-9)
|
||||
const names = new Map(shaped("wall").map((type) => [type.type_id, type.name] as const));
|
||||
if (!names.size) return detail;
|
||||
for (const section of detail.cross_sections) {
|
||||
if (section.revetment) delete (section as { revetment?: unknown }).revetment;
|
||||
}
|
||||
attachWallSpecs(
|
||||
detail.cross_sections as unknown as Array<Record<string, unknown>>,
|
||||
wallSpecsFrom(drafts as unknown as WallStructureInput[], names),
|
||||
);
|
||||
await withDraftShapes(detail, projectId);
|
||||
return withStructureAreas(detail);
|
||||
}
|
||||
|
||||
/** 관 지점 시설 → 횡단 자리 키 = 표 형상(파이썬 `_SECTION_KEYS` · 배관 세트 · 독립 기슭막이 = culvert). */
|
||||
const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const;
|
||||
const SHAPE_KEY: Record<string, (typeof FACILITY_KEYS)[number]> = {
|
||||
box: "box",
|
||||
ford: "ford",
|
||||
ford_pavement: "ford_pavement",
|
||||
};
|
||||
const shapeOf = (facility: string | null | undefined): string =>
|
||||
catalogRow(facility ?? "pipe")?.shape ?? "pipe_set";
|
||||
const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] =>
|
||||
SHAPE_KEY[shapeOf(facility)] ?? "culvert";
|
||||
|
||||
/**
|
||||
* 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다.
|
||||
* 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식
|
||||
* (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다.
|
||||
*/
|
||||
async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise<void> {
|
||||
const pipes = readPendingPipes(projectId);
|
||||
if (!pipes) return;
|
||||
const sections = detail.cross_sections as unknown as Array<Record<string, unknown>>;
|
||||
const near = (spec: Record<string, unknown>, section: Record<string, unknown>, at: number) =>
|
||||
Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M;
|
||||
// 물넘이포장(구간형 · 걸친 측점마다 얹힘) — 옮기거나 지운 옛 자리는 걷음 · 새 자리는 아래에서 얹음(314)
|
||||
for (const section of sections) {
|
||||
const spec = section.ford_pavement as Record<string, unknown> | undefined;
|
||||
const kept = (pipe: (typeof pipes)[number]) =>
|
||||
shapeOf(pipe.facility) === "ford_pavement" && !!spec && near(spec, section, pipe.chainage_m);
|
||||
if (spec && !pipes.some(kept)) delete section.ford_pavement;
|
||||
}
|
||||
/** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */
|
||||
const fits = (pipe: (typeof pipes)[number], key: string, spec: Record<string, unknown>) =>
|
||||
key === facilityKey(pipe.facility ?? "pipe") &&
|
||||
(key !== "culvert" ||
|
||||
(spec.hidden_pipe === true) === (shapeOf(pipe.facility) === "hidden_revet"));
|
||||
const stale = pipes.filter((pipe) => {
|
||||
const here = sections.flatMap((section) =>
|
||||
FACILITY_KEYS.flatMap((key) => {
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : [];
|
||||
}),
|
||||
);
|
||||
// 얹힌 것이 없는 관(새 관 · 옮긴 관)은 미리 뜬 횡단이 맡음 — 물넘이포장만 걸친 기존 측점까지 여기서(314)
|
||||
if (!here.length) return shapeOf(pipe.facility) === "ford_pavement";
|
||||
return !here.some(([key, spec]) => fits(pipe, key, spec));
|
||||
});
|
||||
if (!stale.length) return;
|
||||
const types = await fetchStructureTypes(true).catch(() => []);
|
||||
const registry = registryDefaults(types);
|
||||
for (const section of sections) {
|
||||
for (const key of FACILITY_KEYS) {
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key];
|
||||
}
|
||||
// 카드 제목 구조물 이름 = 표 카드 이름(서버 `pipe_station_label` 짝 · 배관 = 관종 D관경 · 그 밖 = 종류 이름)
|
||||
// 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점
|
||||
const pipe = stale.find((entry) => {
|
||||
const gap = Math.abs(entry.chainage_m - Number(section.chainage_m));
|
||||
return section.structure ? gap < PIPE_MATCH_M : gap < 0.01;
|
||||
});
|
||||
if (!pipe) continue;
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
section.structure = cardTitle(facility, pipe.options, registry[facility]);
|
||||
}
|
||||
attachCulvertSets(sections, buildCulvertSets(stale, registry));
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다.
|
||||
*
|
||||
@@ -225,17 +103,6 @@ export async function loadSectionDetail(
|
||||
return request;
|
||||
}
|
||||
|
||||
/** 서버가 새 상세를 통째로 돌려준 조작(재생성 등) 뒤 캐시를 그 값으로 바꾼다. */
|
||||
export function replaceSectionDetail(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
detail: SectionDetailResponse,
|
||||
): void {
|
||||
cache.set(keyOf(projectId, routeId), detail);
|
||||
writeState("section-detail", detail, projectId, routeId);
|
||||
setGeneralStations(detail.longitudinal?.stations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시를 비운다. routeId를 생략하면 그 프로젝트 전부 —
|
||||
* 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다.
|
||||
|
||||
@@ -67,20 +67,13 @@ function facilityNotes(section: CrossSection): Array<[string, string]> {
|
||||
`B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`,
|
||||
]);
|
||||
}
|
||||
// 세월교 · 박스 흙막이는 높이 빈 칸 = 본체 끝 높이(79-7)라 안내 안 함
|
||||
const own = section.culvert?.hidden_pipe && !section.culvert.body ? section.culvert : null;
|
||||
if (own && [own.inlet, own.outlet].some((side) => side.structure && !side.revet_height_m)) {
|
||||
// 빈칸 경고 한 꼴(323-4) — 형상 필수 칸이 빈 관 · 구조물(관경 · 벽 · 기슭막이 높이 · 맹암거 높이 · 월류
|
||||
// 높이 · 형상 얹기가 측점 계획 자리에 얹음). 세월교 · 박스 흙막이 높이 빈 칸 = 본체 끝 높이(79-7)라 안 셈
|
||||
for (const item of section.blank ?? [])
|
||||
notes.push([
|
||||
"⚠ 기슭막이 높이 없음 — 근입만 그림",
|
||||
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
|
||||
`⚠ ${item} 빈칸`,
|
||||
`${item}을 안 적어 그 그림을 안 그림(수치를 지어내지 않음 · 수량도 미확정) — 시설 칸에 적으면 그림`,
|
||||
]);
|
||||
}
|
||||
if (section.ford_pavement && !section.ford_pavement.depth_m) {
|
||||
notes.push([
|
||||
"⚠ 월류 높이 없음 — 파임 안 그림",
|
||||
"물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림",
|
||||
]);
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchSectionDetail,
|
||||
regenerateSections,
|
||||
type SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { replaceSectionDetail } from "./B06_Section_Section_Store";
|
||||
import { regenerateSections, type SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { loadSectionDetail } from "./B06_Section_Section_Store";
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -38,10 +34,9 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) =
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await regenerateSections(target.projectId, target.routeId, widthM);
|
||||
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다.
|
||||
const fresh = await fetchSectionDetail(target.projectId, target.routeId);
|
||||
// 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다.
|
||||
replaceSectionDetail(target.projectId, target.routeId, fresh);
|
||||
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다 · 공유 캐시도
|
||||
// 새 상세로(B05 가 옛 값을 못 보게) · 초안 측점 · 그림 자료도 같은 길(323-4)
|
||||
const fresh = await loadSectionDetail(target.projectId, target.routeId, { force: true });
|
||||
deps.applyDetail(fresh);
|
||||
showToast(L("B06_Profile_Regenerate_Success"), "success");
|
||||
return true;
|
||||
|
||||
@@ -48,6 +48,7 @@ import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
|
||||
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||||
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
|
||||
import { catalogRow } from "../common_util/common_util_structure_catalog";
|
||||
|
||||
/**
|
||||
* 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체.
|
||||
@@ -104,8 +105,8 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp
|
||||
writeState("berm", spans, projectId, routeId);
|
||||
}
|
||||
|
||||
/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */
|
||||
export const BERM_TYPE_ID = "berm";
|
||||
/** 소단 = 표 형상 berm(323-4 · 파이썬 `load_berm_spans` 짝) — 종류 키로 안 가름. */
|
||||
export const isBermType = (typeId: string): boolean => catalogRow(typeId)?.shape === "berm";
|
||||
|
||||
/**
|
||||
* 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다
|
||||
@@ -126,7 +127,7 @@ export function bermSpansFromStructures(
|
||||
): BermSpan[] {
|
||||
const spans: BermSpan[] = [];
|
||||
for (const item of structures) {
|
||||
if (item.type_id !== BERM_TYPE_ID) continue;
|
||||
if (!isBermType(item.type_id)) continue;
|
||||
const anchor = item.chainage_m ?? item.start_m ?? null;
|
||||
const start = item.start_m ?? anchor;
|
||||
const end = item.end_m ?? anchor;
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import type { RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel";
|
||||
import { setBermPickHost } from "./B06_Section_UI_Cross_Design_Pick";
|
||||
import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist";
|
||||
import { isBermType } from "./B06_Section_UI_Page_Persist";
|
||||
|
||||
/**
|
||||
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그
|
||||
@@ -200,7 +200,7 @@ export function wireStructureSelection(
|
||||
.forEach((box) => box.classList.remove("is-active"));
|
||||
if (chainageM === null) return;
|
||||
const berm = panel.getStructures().find((item) => {
|
||||
if (item.type_id !== BERM_TYPE_ID) return false;
|
||||
if (!isBermType(item.type_id)) return false;
|
||||
const start = item.start_m ?? item.chainage_m ?? -1;
|
||||
const end = item.end_m ?? item.chainage_m ?? -1;
|
||||
return chainageM >= Math.min(start, end) - 1e-6 && chainageM <= Math.max(start, end) + 1e-6;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/* =============================================================================
|
||||
* common_util/common_util_structure_attach.ts
|
||||
* 형상 얹기 한 벌(323-4) — 측점(횡단) + 현황(관 · 구조물) + 구조물 표 → 그 측점 그림 자료.
|
||||
*
|
||||
* 구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 서버와 같은 차례로 다시 얹음 — 관 세트 → 벽 →
|
||||
* 포장 → 맹암거 → 빈칸 경고. 고르기 = 표 형상(322-1). 화면은 초안 ?? 저장분 현황으로 이것 하나를 씀
|
||||
* (`B06_Section_Section_Drafts.withDraftShapes`) — 저장 전 그림 = 저장 뒤 그림.
|
||||
* 파이썬 짝 = `B06_Section/B06_Section_Engine_Attach.py` `attach_shapes`(서버 상세 읽기 · 재생성 응답) ·
|
||||
* 거울 시험 = `resources/tester/test_323_4_attach_mirror.py`.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
type CulvertPipePoint,
|
||||
} from "./common_util_culvert_sets";
|
||||
import {
|
||||
attachWallSpecs,
|
||||
wallSpecsFrom,
|
||||
type WallStructureInput,
|
||||
} from "./common_util_structure_walls";
|
||||
import {
|
||||
attachPavementSpecs,
|
||||
pavementSpecsFrom,
|
||||
type PavementStructureInput,
|
||||
} from "./common_util_structure_pavement";
|
||||
import {
|
||||
attachUnderdrainSpecs,
|
||||
underdrainSpecsFrom,
|
||||
type UnderdrainInput,
|
||||
} from "./common_util_structure_underdrain";
|
||||
import { catalogRow, fieldName } from "./common_util_structure_catalog";
|
||||
import {
|
||||
blankKeys,
|
||||
planStations,
|
||||
SAME_STATION_M,
|
||||
type PlanPipe,
|
||||
type PlanStructure,
|
||||
type PlanType,
|
||||
} from "./common_util_structure_station";
|
||||
import { SHAPE_KEYS } from "./common_util_structure_shape_keys";
|
||||
|
||||
export { SHAPE_KEYS };
|
||||
|
||||
export interface AttachType extends PlanType {
|
||||
name: string;
|
||||
options: ReadonlyArray<{ key: string; label?: string; default: unknown }>;
|
||||
}
|
||||
|
||||
/** 빈 필수 칸 → 「종류 이름 칸 이름」(칸 이름 = 그 종류 정의 → 다른 종류 정의 → 부품 상자 → 키). */
|
||||
function blankLabels(
|
||||
typeId: string,
|
||||
options: Record<string, unknown> | null | undefined,
|
||||
types: ReadonlyArray<AttachType>,
|
||||
): string[] {
|
||||
const name = catalogRow(typeId)?.name ?? typeId;
|
||||
const ordered = [...types.filter((t) => t.type_id === typeId), ...types];
|
||||
return blankKeys(typeId, options).map((key) => {
|
||||
const option = ordered.flatMap((t) => t.options).find((o) => o.key === key);
|
||||
return `${name} ${option?.label ?? fieldName(key) ?? key}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 빈칸 경고 — 측점 계획 자리마다 덮는 관 · 구조물의 빈 필수 칸(파이썬 `_attach_blanks` 짝). */
|
||||
function attachBlanks(
|
||||
sections: Array<Record<string, unknown>>,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
types: ReadonlyArray<AttachType>,
|
||||
): void {
|
||||
const owners = new Map<string, [string, Record<string, unknown> | null | undefined]>();
|
||||
for (const pipe of pipes)
|
||||
owners.set(pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`, [
|
||||
pipe.facility ?? "pipe",
|
||||
pipe.options,
|
||||
]);
|
||||
for (const item of structures)
|
||||
if (item.structure_id) owners.set(item.structure_id, [item.type_id, item.options]);
|
||||
for (const station of planStations(pipes, structures, types)) {
|
||||
if (!station.blank.length) continue;
|
||||
const labels = [
|
||||
...new Set(
|
||||
station.owners.flatMap((owner) => {
|
||||
const hit = owners.get(owner);
|
||||
return hit ? blankLabels(hit[0], hit[1], types) : [];
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (const section of sections)
|
||||
if (Math.abs(Number(section.chainage_m) - station.chainage_m) < SAME_STATION_M)
|
||||
section.blank = labels;
|
||||
}
|
||||
}
|
||||
|
||||
/** 측점마다 그림 자료를 걷고 현황으로 다시 얹음(파이썬 `attach_shapes` 짝 · 제자리 고침). */
|
||||
export function attachShapes(
|
||||
sections: Array<Record<string, unknown>>,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
types: ReadonlyArray<AttachType>,
|
||||
): void {
|
||||
for (const section of sections) for (const key of [...SHAPE_KEYS, "blank"]) delete section[key];
|
||||
const registry = registryDefaults(types);
|
||||
attachCulvertSets(sections, buildCulvertSets(pipes as readonly CulvertPipePoint[], registry));
|
||||
const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape);
|
||||
// 관 세트가 선 측점은 벽을 안 얹음(`attachWallSpecs`) — 차례 = 서버와 같음
|
||||
attachWallSpecs(
|
||||
sections,
|
||||
wallSpecsFrom(
|
||||
structures as unknown as WallStructureInput[],
|
||||
new Map(shaped("wall").map((t) => [t.type_id, t.name] as const)),
|
||||
),
|
||||
);
|
||||
attachPavementSpecs(
|
||||
sections,
|
||||
pavementSpecsFrom(
|
||||
structures as unknown as PavementStructureInput[],
|
||||
new Set(shaped("pavement").map((t) => t.type_id)),
|
||||
),
|
||||
);
|
||||
attachUnderdrainSpecs(sections, underdrainSpecsFrom(structures as UnderdrainInput[]));
|
||||
attachBlanks(sections, pipes, structures, types);
|
||||
}
|
||||
@@ -99,6 +99,11 @@ 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 field_name(key: str) -> str | None:
|
||||
"""어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · TS `fieldName` 짝)."""
|
||||
return (load_parts().get("칸 이름") or {}).get(key)
|
||||
|
||||
|
||||
def shape_needs(type_id: str) -> list[str]:
|
||||
"""형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — TS `shapeNeeds` 짝."""
|
||||
row = catalog_row(type_id)
|
||||
|
||||
@@ -171,6 +171,10 @@ export function cardTitle(
|
||||
}
|
||||
|
||||
/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */
|
||||
/** 어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · 파이썬 `field_name` 짝). */
|
||||
export const fieldName = (key: string): string | undefined =>
|
||||
(PARTS as unknown as { "칸 이름"?: Record<string, string> })["칸 이름"]?.[key];
|
||||
|
||||
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[] }>;
|
||||
|
||||
@@ -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장). 칸 이름 = 어느 종류 정의에도 없는 읽는 칸의 화면 글(빈칸 경고 · 323-4). 표 읽기 = common_util_structure_catalog.ts · .py(짝).",
|
||||
"parts": {
|
||||
"횡단도": {
|
||||
"anchor": {
|
||||
@@ -197,6 +197,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"칸 이름": { "ford_height_m": "월류 높이" },
|
||||
"처음값": {
|
||||
"pipe": {
|
||||
"목록": "both",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/* =============================================================================
|
||||
* common_util/common_util_structure_shape_keys.ts
|
||||
* 측점 그림 자료 열쇠(323-4) — 형상 얹기 한 벌(`common_util_structure_attach.ts`)이 걷고 얹는 것 = 카드 ·
|
||||
* 상세 창 · B07 · 3D 가 읽는 것 = 3D 지문(`B05_Profile_UI_Corridor_Structures_Hash.ts`). 가져오는 것 없음
|
||||
* (3D 도움 파일 로더도 그대로 읽게). 파이썬 짝 = `B06_Section_Engine_Attach.SHAPE_KEYS`.
|
||||
* ========================================================================== */
|
||||
|
||||
export const SHAPE_KEYS = [
|
||||
"culvert",
|
||||
"ford",
|
||||
"box",
|
||||
"ford_pavement",
|
||||
"revetment",
|
||||
"pavement",
|
||||
"underdrain",
|
||||
] as const;
|
||||
@@ -80,6 +80,13 @@ def _empty(value: object) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def blank_keys(type_id: str, options: dict | None) -> list[str]:
|
||||
"""형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝)."""
|
||||
from common_util.common_util_structure_catalog import shape_needs
|
||||
|
||||
return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))]
|
||||
|
||||
|
||||
def _station_marks(
|
||||
start: object, anchor: object, end: object, span3: bool
|
||||
) -> list[tuple[str, float]]:
|
||||
@@ -99,11 +106,9 @@ def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list
|
||||
· 덮는 구조물 id(관 = 자리 이름 `pipe-{누가거리}` · 구조물 = structure_id) · 빈칸 경고(형상
|
||||
부품 필수 칸 중 값이 빈 옵션 키). 표에 없는 종류는 뺌.
|
||||
"""
|
||||
from common_util.common_util_structure_catalog import card_title, catalog_row, shape_needs
|
||||
from common_util.common_util_structure_catalog import card_title, catalog_row
|
||||
|
||||
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))]
|
||||
blank = blank_keys
|
||||
|
||||
marks: list[dict[str, object]] = []
|
||||
for pipe in pipes:
|
||||
|
||||
@@ -170,8 +170,10 @@ export interface PlannedStation {
|
||||
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]));
|
||||
export const blankKeys = (
|
||||
typeId: string,
|
||||
options: Record<string, unknown> | null | undefined,
|
||||
): string[] => shapeNeeds(typeId).filter((key) => empty(options?.[key]));
|
||||
|
||||
/** 관 · 구조물 → 측점 계획(오름차순 · 같은 m 하나). 표에 없는 종류는 뺌. `registry` = 종류 → 옵션
|
||||
* 기본값(카드 이름 빈칸 · 없으면 `types` 정의 기본값 · 횡단 관 세트 `registryDefaults` 와 같은 표). */
|
||||
|
||||
@@ -260,6 +260,52 @@
|
||||
}
|
||||
},
|
||||
"까닭": "★5 관 공식 한 벌 — 표시 이름도 정의 관종을 앎"
|
||||
},
|
||||
{
|
||||
"part": "samples",
|
||||
"key": "ford_pavement/main",
|
||||
"column": "cards",
|
||||
"단계": 4,
|
||||
"다음": {
|
||||
"100.00": {
|
||||
"title": null,
|
||||
"display": null,
|
||||
"shape": {
|
||||
"ford_pavement": true
|
||||
}
|
||||
},
|
||||
"101.00": {
|
||||
"title": "물넘이포장",
|
||||
"display": "물넘이포장",
|
||||
"shape": {
|
||||
"ford_pavement": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"까닭": "323-4 저장 전 그림 = 저장 뒤 그림 — 물넘이포장 폭 절반 걸친 일반 측점(100)도 얹음(서버 attach_shapes 와 같음)"
|
||||
},
|
||||
{
|
||||
"part": "samples",
|
||||
"key": "ford_pavement/blank",
|
||||
"column": "cards",
|
||||
"단계": 4,
|
||||
"다음": {
|
||||
"100.00": {
|
||||
"title": null,
|
||||
"display": null,
|
||||
"shape": {
|
||||
"ford_pavement": true
|
||||
}
|
||||
},
|
||||
"101.00": {
|
||||
"title": "물넘이포장",
|
||||
"display": "물넘이포장",
|
||||
"shape": {
|
||||
"ford_pavement": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"까닭": "323-4 저장 전 그림 = 저장 뒤 그림 — 물넘이포장 폭 절반 걸친 일반 측점(100)도 얹음(서버 attach_shapes 와 같음)"
|
||||
}
|
||||
],
|
||||
"samples": {
|
||||
|
||||
@@ -20,9 +20,9 @@ const STUBS = {
|
||||
},
|
||||
"./B06_Section_Api_Fetch": {
|
||||
regenerateSections: async (_p, _r, width) => calls.regenerate.push(width),
|
||||
fetchSectionDetail: async () => ({ cross_sections: [] }),
|
||||
},
|
||||
"./B06_Section_Section_Store": { replaceSectionDetail: () => {} },
|
||||
// 다시 받기 = 공유 캐시 길(초안 측점 · 그림 자료 · 323-4)
|
||||
"./B06_Section_Section_Store": { loadSectionDetail: async () => ({ cross_sections: [] }) },
|
||||
};
|
||||
|
||||
const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Common.ts");
|
||||
|
||||
@@ -56,6 +56,15 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
|
||||
},
|
||||
},
|
||||
"../common_util/common_util_structure_station": station,
|
||||
// 형상 얹기 한 벌(323-4) 흉내 — 관 자리 측점에 관 그림
|
||||
"@util/common_util_structure_attach": {
|
||||
attachShapes: (sections, pipes) => {
|
||||
for (const pipe of pipes) {
|
||||
const hit = sections.find((s) => Math.abs(s.chainage_m - pipe.chainage_m) < 0.005);
|
||||
if (hit) hit.culvert = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
"./B06_Section_Api_Fetch": {
|
||||
previewStructureSections: async (_projectId, chainages) => {
|
||||
asked.push(chainages);
|
||||
@@ -86,6 +95,7 @@ const chainages = (detail) => detail.cross_sections.map((s) => s.chainage_m);
|
||||
const detail = detailOf();
|
||||
draftPipes = [{ chainage_m: 95, facility: "pipe", source: "user" }]; // 95.38 → 95
|
||||
await drafts.withDraftStations(detail, "P", 1);
|
||||
await drafts.withDraftShapes(detail, "P"); // 그림 자료(323-4 · `Section_Store` 차례)
|
||||
const preview = detail.cross_sections.find((s) => s.chainage_m === 95);
|
||||
out.moved = {
|
||||
chainages: chainages(detail),
|
||||
|
||||
@@ -27,7 +27,7 @@ globalThis.requestAnimationFrame = (fn) => fn();
|
||||
const STUBS = {
|
||||
"./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} },
|
||||
"./B06_Section_UI_Cross_Design_Pick": { setBermPickHost: () => {} },
|
||||
"./B06_Section_UI_Page_Persist": { BERM_TYPE_ID: "berm" },
|
||||
"./B06_Section_UI_Page_Persist": { isBermType: (typeId) => typeId === "berm" },
|
||||
};
|
||||
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
||||
function loadTs(full) {
|
||||
|
||||
@@ -43,9 +43,13 @@ const load = (file) => {
|
||||
return box.exports;
|
||||
};
|
||||
|
||||
const { withDraftStations, forgetDraftStations } = load(
|
||||
path.join(ROOT, "B06_Section", "B06_Section_Section_Drafts.ts"),
|
||||
);
|
||||
const drafts = load(path.join(ROOT, "B06_Section", "B06_Section_Section_Drafts.ts"));
|
||||
const { forgetDraftStations } = drafts;
|
||||
// 상세 맞춤 = 측점 계획 → 형상 얹기(`Section_Store.withDraftWalls` 와 같은 차례 · 323-4)
|
||||
const withDraftStations = async (detail, projectId, routeId) => {
|
||||
await drafts.withDraftStations(detail, projectId, routeId);
|
||||
await drafts.withDraftShapes(detail, projectId);
|
||||
};
|
||||
|
||||
const detail = {
|
||||
cross_sections: [
|
||||
|
||||
@@ -229,6 +229,10 @@ const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedSt
|
||||
B06("B06_Section_Section_Drafts"),
|
||||
path.join(ROOT, "common_util", "common_util_culvert_sets.ts"),
|
||||
path.join(ROOT, "common_util", "common_util_structure_station.ts"),
|
||||
// 그림 자료 = 형상 얹기 한 벌(323-4)
|
||||
...["attach", "shape_keys", "walls", "pavement", "underdrain"].map((name) =>
|
||||
path.join(ROOT, "common_util", `common_util_structure_${name}.ts`),
|
||||
),
|
||||
],
|
||||
{
|
||||
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
|
||||
@@ -246,7 +250,13 @@ const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedSt
|
||||
},
|
||||
},
|
||||
);
|
||||
const { withDraftStations, forgetDraftStations } = load(B06("B06_Section_Section_Drafts"));
|
||||
const drafts = load(B06("B06_Section_Section_Drafts"));
|
||||
const { forgetDraftStations } = drafts;
|
||||
// 상세 맞춤 = 측점 계획 → 형상 얹기(`Section_Store.withDraftWalls` 와 같은 차례)
|
||||
const withDraftStations = async (detail, projectId, routeId) => {
|
||||
await drafts.withDraftStations(detail, projectId, routeId);
|
||||
await drafts.withDraftShapes(detail, projectId);
|
||||
};
|
||||
const D800 = { pipe_kind: "파형강관", pipe_diameter_mm: "800" };
|
||||
const detailWith = (spec) => ({
|
||||
cross_sections: [
|
||||
|
||||
@@ -128,6 +128,7 @@ async function run(setup, detail) {
|
||||
drafts.forgetDraftStations("P");
|
||||
route += 1;
|
||||
await drafts.withDraftStations(detail, "P", route);
|
||||
await drafts.withDraftShapes(detail, "P"); // 그림 자료 = 형상 얹기 한 벌(323-4)
|
||||
return summary(detail);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/* PLAN 323-4 형상 얹기 한 벌 헬퍼 — 진짜 `common_util_structure_attach.ts`(attachShapes) · 3D 지문 ·
|
||||
* 벽 한 번(`B05_Profile_UI_Corridor_Structures_Hash.ts`)을 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON.
|
||||
* 입력 = argv[2] JSON `{definition, types, cases: {이름: {sections, pipes, structures}}}`. 짝 = `test_323_4_attach_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}.ts`, base.replace(/\.js$/, ".ts"), base].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 attach = load(path.join(ROOT, "common_util", "common_util_structure_attach.ts"));
|
||||
const hash = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures_Hash.ts"));
|
||||
catalog.useCatalogDefinition({ types: input.types, ...input.definition });
|
||||
|
||||
const out = { cases: {} };
|
||||
for (const [name, { sections, pipes, structures }] of Object.entries(input.cases)) {
|
||||
attach.attachShapes(sections, pipes, structures, input.types);
|
||||
out.cases[name] = sections;
|
||||
}
|
||||
|
||||
// 3D 벽 한 번 — 옹벽 93 ~ 113(기준 101)이 덮는 측점 넷 중 기준 측점만 · 스윕 = 시작 ~ 끝
|
||||
const wall = { structure_id: "w1", start_m: 93, anchor_m: 101, end_m: 113 };
|
||||
const covered = [93, 100, 101, 113].map((at) => ({ chainage_m: at, revetment: { ...wall } }));
|
||||
out.sweep = covered.map((section) => hash.wallSweepAt(section, covered));
|
||||
|
||||
// 3D 지문 — 손 해시가 빠뜨린 칸(벽 기초 · 포장 · 맹암거)도 바뀌면 다름 · 구조물 없는 측점 = 빈 목록
|
||||
const base = { chainage_m: 0, revetment: { ...wall, foundation: null } };
|
||||
const fp = (section) => JSON.stringify(hash.structureHashParts(section));
|
||||
out.fingerprint = {
|
||||
same: fp(base) === fp({ chainage_m: 0, revetment: { ...wall, foundation: null } }),
|
||||
foundation: fp(base) !== fp({ ...base, revetment: { ...wall, foundation: "기초유" } }),
|
||||
pavement: fp(base) !== fp({ ...base, pavement: [{ type_id: "pavement_concrete" }] }),
|
||||
underdrain: fp(base) !== fp({ ...base, underdrain: { height_m: 0.5 } }),
|
||||
adjust: fp(base) !== fp({ ...base, design: { revet_adjust: { own: { x: 1 } } } }),
|
||||
plain: hash.structureHashParts({ chainage_m: 0 }).length === 0,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -158,11 +158,11 @@ def test_화면_초안_빈_목록은_저장분으로_안_넘어감() -> None:
|
||||
|
||||
|
||||
def test_횡단_벽_초안_빈_목록은_다_지움_그리고_옛_결함_막이() -> None:
|
||||
store = _body(
|
||||
"B06_Section/B06_Section_Section_Store.ts", "async function withDraftWalls(", "\n}\n"
|
||||
)
|
||||
assert "if (!drafts) return withStructureAreas(detail);" in store
|
||||
assert "!drafts.length" not in store
|
||||
# 빈 목록 = 다 지움 — 현황 = 초안 `??` 저장분(빈 목록도 초안) · 형상 얹기가 모든 열쇠를 걷고 다시(323-4)
|
||||
drafts = _read("B06_Section/B06_Section_Section_Drafts.ts")
|
||||
assert "structuresDraft ?? savedOf(projectId)" in drafts
|
||||
assert "const drafts = !!(pipesDraft || structuresDraft);" in drafts
|
||||
assert "!drafts.length" not in drafts
|
||||
# 옛 「놓아도 안 보임」(09-09) — 옛 배열 참조가 `[]` 를 알리던 것. 정본 주입은 배열 안을
|
||||
# 채우고(갈아끼우지 않음) onChange 를 울리지 않아야 함.
|
||||
inject = _body(
|
||||
|
||||
@@ -74,8 +74,9 @@ def test_structure_change_reconciles_and_ford_reattach() -> None:
|
||||
assert "void reconcileStaleDesigns();" in body
|
||||
refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts")
|
||||
assert "Number(section.ford?.overflow_depth_m) > 0" in refresh
|
||||
store = _read("B06_Section", "B06_Section_Section_Store.ts")
|
||||
kinds = store.split("async function withDraftPipeKinds", 1)[1].split("\n}\n", 1)[0]
|
||||
assert "if (!pipes) return;" in kinds
|
||||
assert "delete section.ford_pavement" in kinds
|
||||
assert 'if (!here.length) return shapeOf(pipe.facility) === "ford_pavement";' in kinds
|
||||
# 물넘이포장 옛 자리 걷고 새 자리(걸친 측점까지) 얹기 = 형상 얹기 한 벌(323-4 · 모든 열쇠 걷고 다시 ·
|
||||
# 동작 시험 = test_323_4_attach_mirror)
|
||||
drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts")
|
||||
assert "attachShapes(" in drafts
|
||||
keys = _read("common_util", "common_util_structure_shape_keys.ts")
|
||||
assert '"ford_pavement",' in keys
|
||||
|
||||
@@ -36,7 +36,7 @@ ROOT = HERE.parent.parent
|
||||
GOLDEN = HERE / "golden_322_structures.json"
|
||||
FIXTURE = HERE / "fixtures" / "b06_73_4_body_guard_sections.json"
|
||||
#: 지금 단계 — ★ 칸은 `단계` 가 이 값 이하면 「다음」 값과 맞춤
|
||||
STAGE = 1
|
||||
STAGE = 4
|
||||
GRID = [float(at) for at in range(0, 201, 20)]
|
||||
SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain")
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""PLAN 323-4 형상 얹기 한 벌 — 파이썬 `attach_shapes` = TS `attachShapes`(거울) · 저장 전 그림 = 저장 뒤 그림.
|
||||
|
||||
- 42종 견본(관 지점 · 구조물 · 정의 기본값) + 빈칸 견본 → 측점마다 그림 자료(관 세트 · 벽 · 포장 · 맹암거) ·
|
||||
빈칸 경고 `blank`(「종류 칸」 · 측점 계획 자리)가 서버 = 화면.
|
||||
- 옛 열쇠는 걷고 다시(물넘이포장을 옮기면 옛 자리 파임이 빠짐 · 걸친 측점에 새로).
|
||||
- 3D — C군 벽은 덮는 측점 중 기준 측점에서만 시작 ~ 끝 스윕 · 지문 = 그림 자료 통째(손 해시가 빠뜨린 칸도).
|
||||
TS 쪽 = `helper_323_4_attach.cjs`(Node).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_definition
|
||||
from B06_Section.B06_Section_Engine_Attach import attach_shapes
|
||||
from common_util.common_util_drainage_pipes import parse_pipe_points
|
||||
from common_util.common_util_structure_catalog import catalog
|
||||
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="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
TYPES = with_visible().model_dump()["types"]
|
||||
|
||||
|
||||
def _defaults(type_id: str) -> dict:
|
||||
"""정의 기본값(빈 값 뺌) — 그림이 서는 견본."""
|
||||
options = next(t for t in TYPES if t["type_id"] == type_id)["options"]
|
||||
return {o["key"]: o["default"] for o in options if o["default"] not in (None, "")}
|
||||
|
||||
|
||||
def _samples() -> tuple[list[dict], list[dict]]:
|
||||
"""42종 하나씩(30 m 간격 · 구간형 = 기준 ± 5) + 빈칸 셋(관경 · 옹벽 높이 · 맹암거 높이)."""
|
||||
pipes: list[dict] = []
|
||||
structures: list[dict] = []
|
||||
rows = sorted(catalog().values(), key=lambda row: row["typeId"])
|
||||
for index, row in enumerate(rows):
|
||||
at = 15.0 + 30.0 * index
|
||||
options = _defaults(row["typeId"])
|
||||
if row["owner"] == "pipe_points":
|
||||
pipes.append({"chainage_m": at, "facility": row["typeId"], "options": options})
|
||||
continue
|
||||
span = {"start_m": at - 5, "end_m": at + 5} if row["placement"] == "interval" else {}
|
||||
structures.append(
|
||||
{
|
||||
"structure_id": f"s{index}",
|
||||
"type_id": row["typeId"],
|
||||
"placement": row["placement"],
|
||||
"chainage_m": at,
|
||||
"options": options,
|
||||
**span,
|
||||
}
|
||||
)
|
||||
end = 15.0 + 30.0 * len(rows)
|
||||
pipes.append({"chainage_m": end, "facility": "pipe", "options": {"pipe_diameter_mm": ""}})
|
||||
structures.append(
|
||||
{
|
||||
"structure_id": "blank-wall",
|
||||
"type_id": "retaining_wall",
|
||||
"placement": "interval",
|
||||
"start_m": end + 25,
|
||||
"chainage_m": end + 30,
|
||||
"end_m": end + 35,
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
structures.append(
|
||||
{
|
||||
"structure_id": "blank-drain",
|
||||
"type_id": "cross_underdrain",
|
||||
"placement": "point",
|
||||
"chainage_m": end + 60,
|
||||
"options": {},
|
||||
}
|
||||
)
|
||||
return pipes, structures
|
||||
|
||||
|
||||
def _sections(until: float) -> list[dict]:
|
||||
"""5 m 마다 측점(모든 기준 · 시작 · 끝 자리) — 옛 열쇠를 미리 얹어 걷힘도 봄."""
|
||||
out = []
|
||||
for step in range(int(until // 5) + 2):
|
||||
section = {"chainage_m": float(step * 5)}
|
||||
if step == 1:
|
||||
section["ford_pavement"] = {"type": "ford_pavement", "chainage_m": 5.0} # 옛 자리
|
||||
out.append(section)
|
||||
return out
|
||||
|
||||
|
||||
PIPES, STRUCTURES = _samples()
|
||||
UNTIL = 15.0 + 30.0 * len(catalog()) + 60
|
||||
CASES = {
|
||||
"all": {"sections": _sections(UNTIL), "pipes": PIPES, "structures": STRUCTURES},
|
||||
"empty": {"sections": _sections(30), "pipes": [], "structures": []},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts() -> dict:
|
||||
payload = {"definition": load_definition(), "types": 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_323_4_attach.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]:
|
||||
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))
|
||||
|
||||
|
||||
def test_mirror_python_equals_ts(ts: dict) -> None:
|
||||
for name, case in CASES.items():
|
||||
py, js = _py(case), ts["cases"][name]
|
||||
assert len(py) == len(js), name
|
||||
for left, right in zip(py, js):
|
||||
assert left == right, (name, left["chainage_m"])
|
||||
|
||||
|
||||
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 {
|
||||
"culvert",
|
||||
"ford",
|
||||
"box",
|
||||
"ford_pavement",
|
||||
"revetment",
|
||||
"pavement",
|
||||
"underdrain",
|
||||
} <= keys
|
||||
old = ts["cases"]["all"][1] # 옛 물넘이 자리(5 m) — 걷힘
|
||||
assert "ford_pavement" not in old
|
||||
assert all(len(section) == 1 for section in ts["cases"]["empty"])
|
||||
|
||||
|
||||
def test_blank_chips(ts: dict) -> None:
|
||||
end = 15.0 + 30.0 * len(catalog())
|
||||
by_at = {s["chainage_m"]: s.get("blank") for s in ts["cases"]["all"]}
|
||||
assert by_at[end] == ["배관 관경"]
|
||||
assert by_at[end + 25] == by_at[end + 30] == by_at[end + 35] == ["옹벽 높이"]
|
||||
assert by_at[end + 60] == ["맹암거 암거 높이"]
|
||||
assert by_at[end + 5] is None # 계획 자리만
|
||||
|
||||
|
||||
def test_wall_sweeps_once(ts: dict) -> None:
|
||||
assert ts["sweep"] == [None, None, {"beforeM": 8, "afterM": 12}, None]
|
||||
|
||||
|
||||
def test_fingerprint_covers_drawing(ts: dict) -> None:
|
||||
assert ts["fingerprint"] == {
|
||||
"same": True,
|
||||
"foundation": True,
|
||||
"pavement": True,
|
||||
"underdrain": True,
|
||||
"adjust": True,
|
||||
"plain": True,
|
||||
}
|
||||
@@ -57,6 +57,7 @@ def test_물넘이_월류_높이가_비면_파임_없음() -> None:
|
||||
|
||||
def test_카드_머리가_목록과_까닭을_보인다() -> None:
|
||||
assert "기본값(미확정)" in CHROME
|
||||
assert "월류 높이 없음 — 파임 안 그림" in CHROME
|
||||
# 월류 높이 · 기슭막이 높이 등 필수 칸 빈칸 = 빈칸 경고 한 꼴(323-4 · 형상 얹기가 얹은 `blank`)
|
||||
assert "section.blank" in CHROME and "빈칸`" in CHROME
|
||||
# 조작으로 값이 오면 목록에서 뺌 — 캐시를 바로 고치는 길.
|
||||
assert CONTROLS.count("dropDefaulted(spec,") == 4
|
||||
|
||||
Reference in New Issue
Block a user