diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index 9b1e7b79e..9f1371201 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -18,6 +18,7 @@ import { BOX_SPEC } from "./design_structure_box"; import { FORD_SPEC } from "./design_structure_ford"; import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement"; import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road"; +import { baseType } from "../../common_util/common_util_structure_catalog"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const BASIN = "집수정"; @@ -116,3 +117,7 @@ export const STRUCTURE_SPECS: Readonly> = { pavement_concrete: PAVEMENT_SPEC, etc: ETC_SPEC, }; + +/** 그 종류의 명세 — 복사한 종류는 본보기 명세(323-7 · `baseType`). 화면은 이것으로 고름(종류 키 직접 안 씀). */ +export const specFor = (typeId: string): StructureSpec | undefined => + STRUCTURE_SPECS[baseType(typeId)]; diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index f94f563b5..f8078b5a4 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -471,17 +471,10 @@ export async function fetchRoadInflow( export type PipeSource = "stream" | "spacing" | "user"; /** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에 - * 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */ + * 따라 택일한다 — 관 지점 정본 종류 = 구조물 표 정본(`catalogRow().owner` · 323-7) · 이름 = 표 이름 · + * 형상마다 손코딩 폼이 있어 종류 더하기(본보기)에서 뺌. 교량은 임도용이 아니다. */ export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment"; -export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [ - ["pipe", "배수관"], - ["box_culvert", "BOX암거"], - ["ford_pavement", "물넘이포장"], - ["ford_bridge", "세월교"], - ["revetment", "기슭막이"], -]; - export interface DetailPipePoint { chainage_m: number; lonlat: [number, number]; diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index ea2106c23..b8665eb5f 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -79,7 +79,6 @@ export interface StructureType { placement: StructurePlacement; options: StructureOptionField[]; style: { color?: string; abbr?: string }; - drawing_views: string[]; /** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */ managed_by: string | null; /** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름. diff --git a/B05_Profile/B05_Profile_Structures_Migration.py b/B05_Profile/B05_Profile_Structures_Migration.py index 0d8d33c52..b4703ddc2 100644 --- a/B05_Profile/B05_Profile_Structures_Migration.py +++ b/B05_Profile/B05_Profile_Structures_Migration.py @@ -16,15 +16,16 @@ """ import re +from collections.abc import Sequence from dataclasses import dataclass, field from typing import Any, Iterable from B05_Profile.B05_Profile_Structures_Schema import ( StructureInstance, - all_type_names, structure_type_map, ) from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint +from common_util.common_util_structure_station import SAME_STATION_M PIPE_STRUCTURE_NAME = "배관" # 구조물 정본이 아니라 관 지점 정본이 관리한다는 표식(레지스트리 `managed_by`). @@ -54,25 +55,22 @@ _PIPE_LABEL = re.compile(r"^(배관|.*관)\s+D\d+", re.UNICODE) _ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE) -def _managed_elsewhere_labels() -> set[str]: - """정본이 이미 관리하는 측점 이름 — 구조물로 재이관하면 안 된다(정본 이중화 → "기타" 고스트). +def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bool: + """정본(관 지점 · 구조물)이 이미 관리하는 측점인가 — 재이관하면 정본 이중화 → 「기타」 유령. - 지금 판 · 지난 판 정의의 **종류 이름 전부**(322-0) — 심는 측점 이름 · 관 지점 시설 이름만 - 보던 옛 규칙은 M02 에서 종류 이름을 바꾸면 옛 이름 측점을 못 알아보고 「기타」로 굳혔다 - (이름 바꿈 · 심는 규칙 바뀜 둘 다). + 자리로 가름(323-7 · 지금 관 · 구조물이 심는 측점 계획 자리 `planted`) — 옛 이름 되짚기 + (지난 판 이름 전부 · 322-0)는 걷음 · 이름을 바꿔도 자리는 그대로라 옛 이름 측점도 알아봄. + 지금 판 종류 이름 · 관 라벨 꼴(관종 D관경)은 자리를 못 받은 부름(옛 확정분만)을 위해 그대로. """ - return all_type_names() - - -def _is_managed_elsewhere(entry: dict[str, Any]) -> bool: - """다른 정본(pipe_points.json·구조물 A군)이 관리하는 측점인가.""" label = str(entry.get("structure", "")).strip() + at = float(entry.get("chainage_m", 0.0)) return ( entry.get("origin") == "pipe" or entry.get("structureType") == PIPE_STRUCTURE_NAME or label == PIPE_STRUCTURE_NAME - or label in _managed_elsewhere_labels() + or label in {item.name for item in structure_type_map().values()} or bool(_PIPE_LABEL.match(label)) + or any(abs(at - p) < SAME_STATION_M for p in planted) ) @@ -104,7 +102,9 @@ def _escape_width_of(entry: dict[str, Any]) -> float | None: return None -def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPlan: +def migrate_irregular_stations( + entries: Iterable[dict[str, Any]], planted: Sequence[float] = () +) -> MigrationPlan: """기존 비정규 측점 목록을 정본별로 나눠 옮긴다(같은 입력이면 같은 결과). 타입이 다른 정본 소관(`managed_by`)이면 구조물 목록이 아니라 그 정본으로 간다 — @@ -117,7 +117,7 @@ def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPl seen: set[tuple[str, float]] = set() for entry in entries: - if _is_managed_elsewhere(entry): + if _is_managed_elsewhere(entry, planted): continue chainage = float(entry.get("chainage_m", 0.0)) diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index 2114b94ce..a21bab4a4 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -43,6 +43,7 @@ from common_util.common_util_drainage_pipes import ( read_pipe_points_file, ) from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_structure_station import plan_stations from config.config_db import get_db_pool from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible @@ -193,7 +194,14 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) revision, existing = load_structures(root) migrated_before = load_migrated_legacy(root) - plan = migrate_irregular_stations(payload.stations) + # 정본이 이미 심는 자리(측점 계획 · 323-7) — 이름이 아니라 자리로 가름(옛 이름 되짚기 걷음) + planted = [ + float(station["chainage_m"]) + for station in plan_stations( + read_pipe_points_file(pipe_points_path_in(Path(root))), existing + ) + ] + plan = migrate_irregular_stations(payload.stations, planted) # 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, # 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다 # (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본). diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 5d37d7eae..ec6a6d0f2 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -88,8 +88,6 @@ class StructureType(BaseModel): placement: Placement options: list[StructureOptionField] = Field(default_factory=list) style: dict[str, str] = Field(default_factory=dict) - # B06 도면 연계용 메타데이터 — 저장만 하고 이번 단계 완료 조건에는 넣지 않는다. - drawing_views: list[str] = Field(default_factory=list) # 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다. managed_by: str | None = None # 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다 @@ -116,10 +114,19 @@ def _read_definition(path: str, _mtime: int, _size: int) -> dict[str, Any]: return json.load(handle) +#: 정의에 남은 옛 칸 — 아무도 안 읽어 걷음(323-7 · 마스터는 M02 [저장] 때 빠짐 · 읽을 때 버림). +RETIRED_TYPE_KEYS = ("drawing_views",) + + +def without_retired(item: dict[str, Any]) -> dict[str, Any]: + """종류 한 줄에서 옛 칸을 뺀 사본.""" + return {k: v for k, v in item.items() if k not in RETIRED_TYPE_KEYS} + + @lru_cache(maxsize=4) def _read_types(path: str, mtime: int, size: int) -> tuple[StructureType, ...]: payload = _read_definition(path, mtime, size) - return tuple(StructureType.model_validate(item) for item in payload["types"]) + return tuple(StructureType.model_validate(without_retired(item)) for item in payload["types"]) def load_definition() -> dict[str, Any]: @@ -156,22 +163,6 @@ def is_station_planting_type(item: StructureType) -> bool: HISTORY_DIRNAME = "구조물정의_이력" -def all_type_names() -> set[str]: - """지금 판 + 지난 판 정의의 종류 이름 전부 — 이름을 바꾼 뒤 옛 이름 측점도 알아봄(322-0).""" - names = {item.name for item in load_structure_types()} - history = os.path.join(os.path.dirname(DEFINITION_PATH), HISTORY_DIRNAME) - for name in os.listdir(history) if os.path.isdir(history) else []: - if not (name.startswith("판_") and name.endswith(".json")): - continue - try: - with open(os.path.join(history, name), encoding="utf-8") as handle: - types = json.load(handle).get("types", []) - except (OSError, ValueError): - continue - names |= {str(t["name"]) for t in types if isinstance(t, dict) and t.get("name")} - return names - - def registry_schema_version() -> int: return int(_read_definition(*_stamp()).get("schema_version", 1)) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 876d5f38d..253be24d5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -57,6 +57,7 @@ import { type SpecLinks, } from "../A00_Common/design/design_structure_form"; import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; +import { catalogRow } from "../common_util/common_util_structure_catalog"; import { BOX_SPEC, bodyOf, @@ -496,13 +497,16 @@ export function createFacilityOptionsForm( ); let current: PipeFacility | null = null; + /** 서브폼 = 구조물 표 형상(323-7 · 종류 키 대신 · 관 지점 시설은 형상마다 손코딩 폼 하나). */ + const shapeOf = (facility: PipeFacility | null) => + (facility && catalogRow(facility)?.shape) || null; function syncVisibility(): void { root.hidden = current === null; if (current === null) return; - const isPipe = current === "pipe"; - const isBox = current === "box_culvert"; - const isBridge = current === "ford_bridge"; + const isPipe = shapeOf(current) === "pipe_set"; + const isBox = shapeOf(current) === "box"; + const isBridge = shapeOf(current) === "ford"; pipeForm.root.hidden = !isPipe; boxForm.root.hidden = !isBox; fordForm.root.hidden = !isBridge; @@ -512,8 +516,8 @@ export function createFacilityOptionsForm( wingOutFields.root.hidden = true; (spec ? spec.slot("inlet") : wingInHome).append(wingInFields.slot); (spec ? spec.slot("outlet") : wingOutHome).append(wingOutFields.slot); - paveForm.root.hidden = current !== "ford_pavement"; - const isRevet = current === "revetment"; + paveForm.root.hidden = shapeOf(current) !== "ford_pavement"; + const isRevet = shapeOf(current) === "hidden_revet"; revetTopRow.hidden = !isRevet; // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임). const side = revetSide.value || "양쪽"; @@ -580,10 +584,10 @@ export function createFacilityOptionsForm( }, setChainage(chainageM) { chainage = chainageM; - if (current === "pipe") pipeForm.refresh(); + if (shapeOf(current) === "pipe_set") pipeForm.refresh(); }, recount() { - if (current === "pipe") pipeForm.recount(); + if (shapeOf(current) === "pipe_set") pipeForm.recount(); }, onInletStructureChange(handler) { inletStructureHandler = handler; @@ -658,28 +662,28 @@ export function createFacilityOptionsForm( }, setDesignFlow(designFlow) { fordFlow = designFlow ?? null; - if (current === "ford_bridge") fordFlowBox.fill(); - if (current === "ford_pavement") paveFlowBox.fill(); + if (shapeOf(current) === "ford") fordFlowBox.fill(); + if (shapeOf(current) === "ford_pavement") paveFlowBox.fill(); syncVisibility(); }, readOptions() { const options: Record = {}; // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). - if (current === "pipe") { + if (shapeOf(current) === "pipe_set") { Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) // 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13) if (inletIsAuto()) { if (inletAuto!.structure === undefined) delete options.inlet_structure; else options.inlet_structure = inletAuto!.structure; } - } else if (current === "box_culvert") { + } else if (shapeOf(current) === "box") { // 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2) Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0])); - } else if (current === "ford_pavement") { + } else if (shapeOf(current) === "ford_pavement") { Object.assign(options, paveForm.read()); // 표 03 명세 값 · 옛 월류 · 경사 · 두께 · 길이는 병합이 이어 붙임(52-3) - } else if (current === "ford_bridge") { + } else if (shapeOf(current) === "ford") { Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4) - } else if (current === "revetment") { + } else if (shapeOf(current) === "hidden_revet") { if (revetSide.value) options.side = revetSide.value; // 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고 // 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제). diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index a7d00fefa..cf126dcc4 100644 --- a/B05_Profile/B05_Profile_UI_Page_Helpers.ts +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -16,7 +16,6 @@ import type { RoutePointKind, } from "./B05_Profile_UI_Markers"; import type { CirclePoint, RouteLatestResponse, RoutePoint } from "./B05_Profile_Api_Fetch"; -import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch"; import { irregularLabel, @@ -226,15 +225,6 @@ export function interpolateIrregularStations( }); } -/** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지 따로 붙인다). */ -export const FACILITY_NAMES: Record = { - pipe: "배수관", - box_culvert: "BOX암거", - ford_pavement: "물넘이포장", - ford_bridge: "세월교", - revetment: "기슭막이", -}; - /* ── 측점 상단측(=측구 방향) 사용자 변경분 세션 보관 ───────────────────── * 3D 램프 클릭으로 바꾼 값. 경로 확정 때 uphill_overrides로 백엔드에 병합한다. * 화면 본체가 700줄에 닿아 읽기·쓰기만 여기로 뺐다(2026-09-04, 동작 불변). */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts index 0cebd0aa0..1c30e739c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts @@ -14,7 +14,7 @@ import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import { shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box"; import { withLegacyGuards } from "../A00_Common/design/design_structure_ford"; import { @@ -43,7 +43,7 @@ export function balloonLines( head: { name: string; position: string }, count?: ContainerCount, ): string[] { - const spec = STRUCTURE_SPECS[structure.type_id]; + const spec = specFor(structure.type_id); const values = ( spec ? specLines(spec, structure, type, count) : groupLines(structure.options ?? {}, type) ).filter(Boolean); @@ -158,7 +158,7 @@ export function structureOptionLines( const kept = parts.filter(Boolean); return kept.length ? [`${title}: ${kept.join(SEP)}`] : []; }; - const spec = STRUCTURE_SPECS[structure.type_id]; + const spec = specFor(structure.type_id); if (spec) { const names = specNames(structure as StructureInstance, type); const state = formState(spec, structure.type_id, options); diff --git a/B05_Profile/B05_Profile_UI_Structures_Define.ts b/B05_Profile/B05_Profile_UI_Structures_Define.ts index c964d5623..752627e1b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Define.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Define.ts @@ -186,7 +186,10 @@ export function shapeFields( export interface StructureDefinition { 판?: number; 구조물군?: Record; - 종류?: Record }>; + 종류?: Record< + string, + { 기준측점?: boolean; 동작?: string[]; 이름?: Record; 본보기?: string } + >; 그룹?: Record>; 배치?: Record>; 규칙?: DefinedRule[]; diff --git a/B05_Profile/B05_Profile_UI_Structures_Groups.ts b/B05_Profile/B05_Profile_UI_Structures_Groups.ts index 71bdf7beb..aac595a29 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Groups.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Groups.ts @@ -23,7 +23,7 @@ import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; import { numberInput } from "./B05_Profile_UI_Structures_Fields"; import { grid, group as boxOf, labeled, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { GROUP_JOIN, type GroupItems, @@ -117,7 +117,7 @@ export function createGroupBoxes(): GroupBoxes { shapeInputs.clear(); current = type; // 명세 종류(PLAN 52 · 배관)는 컨테이너가 잇기 줄을 가짐 — 그룹 상자를 안 세움 - const groups = type && STRUCTURE_SPECS[type.type_id] ? [] : (type?.groups ?? []); + const groups = type && specFor(type.type_id) ? [] : (type?.groups ?? []); const boxes: HTMLElement[] = []; bodies = []; // 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식) @@ -200,9 +200,7 @@ export function createGroupBoxes(): GroupBoxes { bodies.push(body); } const stale = - type && !STRUCTURE_SPECS[type.type_id] - ? paintStale(new Set(groups.map((g) => g.id))) - : null; + type && !specFor(type.type_id) ? paintStale(new Set(groups.map((g) => g.id))) : null; root.replaceChildren(...boxes, ...(stale ? [stale] : [])); root.hidden = !type || (!boxes.length && !stale); // 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로 diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index 4efc915fc..424ab6a62 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -22,7 +22,7 @@ import { summaryTooltip, type SummaryItem, } from "../common_util/common_util_summary_item"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; import { @@ -103,7 +103,7 @@ export function linkLamps( ): LinkLamp[] { const links = readGroupItems(source.group_items); const summary = readSummaryItem(source.summary_item); - const spec = STRUCTURE_SPECS[source.type_id]; + const spec = specFor(source.type_id); if (spec?.containers.length) { const groupNames = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); // 배관 흙막이 = 명세 이름 「흙막이」가 정의 옛 이름 「흙막이 추가」보다 앞섬(54-1 · 시설 폼과 같음) diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_AddAt.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_AddAt.ts index 22d989a3b..be20709b1 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_AddAt.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_AddAt.ts @@ -10,10 +10,10 @@ import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structu import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; import { showToast } from "@ui/ui_template_elements"; -/** 바로 추가하지 않는 종류 — 노면공(겹침 확인창). */ -const FORM_ONLY = new Set(["pavement_concrete"]); -/** 월류 높이를 유량 뒤에 받는 종류 — 추가 때 알림 한 줄. */ -const FORD_KINDS = new Set(["ford_bridge", "ford_pavement"]); +/** 바로 추가하지 않는 동작 — 포장 · 물넘이 겹침(확인창 · 323-7 종류 키 대신 동작 이름). */ +const FORM_ONLY = "포장_물넘이_겹침"; +/** 월류 높이를 유량 뒤에 받는 동작 — 추가 때 알림 한 줄. */ +const FORD_KINDS = "월류단면"; export const FORD_WAIT_NOTICE = "월류 높이는 배수유역 재계산(설계유량)이 오면 채워집니다."; /** 우클릭 한 번으로 [추가]까지 끝낼 수 있는지 — [추가]가 막는 빈 필수 칸이 없어야 함. */ @@ -21,7 +21,7 @@ export function addsAtOnce( type: StructureType, optionInputs: ReadonlyArray<{ required: boolean; isEmpty: () => boolean }>, ): boolean { - if (FORM_ONLY.has(type.type_id)) return false; + if (type.behaviors?.includes(FORM_ONLY)) return false; return !optionInputs.some((entry) => entry.required && entry.isEmpty()); } @@ -40,7 +40,7 @@ export async function finishAdd(ctx: { // B05 는 재계산 뒤 배수유역 패널이 새 관 고름을 다시 알려 폼이 열림. await ctx.commit(); ctx.pick.selectPipeByChainage(ctx.anchorM); - if (FORD_KINDS.has(ctx.type.type_id)) showToast(FORD_WAIT_NOTICE); + if (ctx.type.behaviors?.includes(FORD_KINDS)) showToast(FORD_WAIT_NOTICE); return true; } const before = new Set(ctx.structures.map((entry) => entry.structure_id)); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 342f44e2c..aabcc1501 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -22,7 +22,7 @@ import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary"; import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures_Spec"; import { asRoad } from "./B05_Profile_UI_Structures_Define"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { snapStructureStation } from "../common_util/common_util_structure_station"; import { readGroupItems, @@ -117,7 +117,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance const links = readGroupItems(target.group_items); const summary = readSummaryItem(target.summary_item); // 정의 그룹 종류는 종류 단위 잇기 줄이 없음 — 옛 종류 단위 값은 첫 그룹 컨테이너 잇기로 읽음(54-6) - const first = type && !STRUCTURE_SPECS[type.type_id] ? type.groups?.[0]?.id : undefined; + const first = type && !specFor(type.type_id) ? type.groups?.[0]?.id : undefined; const moved = !!(first && summary && !links[first]); ctx.setSummary(moved ? null : summary); ctx.groupBoxes.set(moved ? { ...links, [first!]: summary! } : links); @@ -320,9 +320,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis ); // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. // 범위 직접 입력형(노면공 · 87-1) = 기준 칸 없음 → 늘 시점. - anchor = STRUCTURE_SPECS[type.type_id]?.byRange - ? start - : (ctx.anchorFields.read(step, false) ?? start); + anchor = specFor(type.type_id)?.byRange ? start : (ctx.anchorFields.read(step, false) ?? start); if (anchor < start || anchor > end) { ctx.anchorFields.station.classList.add("is-invalid"); return stop(ctx.anchorFields.station, "기준 측점(시작 ~ 끝 안)"); @@ -357,7 +355,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis // 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다 // (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다. const spans = - type.type_id === "pavement_concrete" && start !== null && end !== null + type.behaviors?.includes("포장_물넘이_겹침") && start !== null && end !== null ? await splitPavementRange(start, end, ctx.pipeFacilities()) : [{ start, end }]; if (!spans) return; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index f10f307d8..6c3227f2a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -21,14 +21,15 @@ import { } from "./B05_Profile_UI_Structures_Define"; import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { stepper } from "./B05_Profile_UI_Drainage_Facility_Fields"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; +import { baseType } from "../common_util/common_util_structure_catalog"; import { paintStructureSpec, specOwnedKeys } from "./B05_Profile_UI_Structures_Spec"; import { solveSplit, specKeys, type SplitRole } from "../A00_Common/design/design_structure_spec"; /** 종류 단위 「구조물 조합」 줄을 가릴 종류인지 — 구조물 명세 컨테이너 · 정의 그룹 상자(그룹마다 잇기 줄)로 * 그리는 종류(PLAN 52-1 · 52-6b · 52-7) · 종단배수(B군 · 조합 잇기 없음 · 표 07). */ export const hidesTypeSummary = (type: StructureType): boolean => - !!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length || type.group === "B"; + !!specFor(type.type_id)?.containers.length || !!type.groups?.length || type.group === "B"; /** 길이 = 전 + 후 를 배관 맞물림(`solveSplit` · 마지막에 고친 둘을 지킴)으로 푸는 종류(표 07 가드레일 · 표 09 흙막이 · 옹벽). */ const SOLVE_SPLIT = new Set(["guardrail", "soil_guard", "retaining_wall"]); @@ -150,7 +151,7 @@ export function renderOptionFields( "length" | "before_m" | "after_m"; const refresh = (normalize: boolean): void => { input.classList.remove("is-invalid"); - if (SOLVE_SPLIT.has(type!.type_id)) { + if (SOLVE_SPLIT.has(baseType(type!.type_id))) { solveInto(option.key); // 타이핑 중에도 — change 의 실시간 반영이 푼 값을 읽게 ctx.syncRangeDisplay(); } else ctx.syncSplitInputs(source, normalize); @@ -273,7 +274,7 @@ export function paintStale( shapeKeys: string[] = [], ): void { // 명세 종류(52) — 명세가 뺀 옛 키(retired)는 지난 정의 값 · 선택 컨테이너 키(머리)는 앎 - const spec = type ? STRUCTURE_SPECS[type.type_id] : undefined; + const spec = type ? specFor(type.type_id) : undefined; const own = spec ? specKeys(spec) : { keys: [], prefixes: [] }; const known = [ ...shapeKeys, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 9f99606ef..f47e9df0a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -20,7 +20,7 @@ import { } from "./B05_Profile_Api_Structures"; import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection"; import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { catalogRow } from "../common_util/common_util_structure_catalog"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. @@ -235,7 +235,7 @@ export function readBeforeOption(inputs: OptionEntry[]): number { /** 시작 · 종료 측점만 받는 명세 종류인가(노면공 · 87-1) — 기준 측점 칸 없음 · 길이 출력. */ export const isRangeOnly = (type: { type_id: string } | null): boolean => - !!type && !!STRUCTURE_SPECS[type.type_id]?.byRange; + !!type && !!specFor(type.type_id)?.byRange; /** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이 * 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만 diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts index 261b06514..56a1661f3 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Spec.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -15,8 +15,9 @@ import { type SpecFormHooks, type SpecLinks, } from "../A00_Common/design/design_structure_form"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier"; +import { baseType } from "../common_util/common_util_structure_catalog"; import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec"; import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import type { WideningBox } from "./B05_Profile_UI_Structures_Widening"; @@ -27,7 +28,7 @@ const widenings = new WeakMap(); /** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌 · 정의 그룹 종류 덧붙임(178-3 추가 단)도. */ export const structureSpecOf = (type: StructureType | null): StructureSpec | null => - (type && !type.managed_by && (STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id])) || + (type && !type.managed_by && (specFor(type.type_id) ?? ADDON_SPECS[baseType(type.type_id)])) || null; /** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 48e1f4b56..4e895efe1 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -31,6 +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 config.config_system import ( DRAINAGE_CACHE_DIRNAME, DRAINAGE_EDITS_DIRNAME, @@ -39,8 +40,8 @@ from config.config_system import ( logger = logging.getLogger(__name__) -# 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. -_CHAINAGE_TOLERANCE_M = 0.02 +# 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m) — 같은 자리 거리 한 곳(323-7) +_CHAINAGE_TOLERANCE_M = EDGE_TOLERANCE_M # 세트 종류 -> 횡단 dict 키. 그림이 다른 시설끼리 소비처가 섞이지 않게 나눠 둔다. _SECTION_KEYS = {"ford": "ford", "box": "box", "ford_pavement": "ford_pavement"} diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index ad661e1df..88607f211 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -24,12 +24,13 @@ 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 common_util.common_util_structure_catalog import catalog_row +from common_util.common_util_structure_catalog import base_type, catalog_row +from common_util.common_util_structure_station import EDGE_TOLERANCE_M logger = logging.getLogger(__name__) -# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다. -_EDGE_TOLERANCE_M = 0.02 +# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 같은 자리 거리 한 곳(323-7) +_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M def _wall_form(type_id: str) -> str | None: @@ -58,7 +59,7 @@ def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]: return [ { "form": "콘크리트" - if type_id == "retaining_wall" + if base_type(type_id) == "retaining_wall" # 본보기 포함(323-7) else (options.get(f"tier_{n}_form") or None), "height_m": _num(options.get(f"tier_{n}_height")), } diff --git a/B06_Section/B06_Section_Engine_Tilt.py b/B06_Section/B06_Section_Engine_Tilt.py index fb0152f13..6717e5d04 100644 --- a/B06_Section/B06_Section_Engine_Tilt.py +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -18,10 +18,11 @@ from typing import Any from B05_Profile.B05_Profile_Structures_Repository import load_structures from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets from common_util.common_util_structure_catalog import catalog_row +from common_util.common_util_structure_station import EDGE_TOLERANCE_M logger = logging.getLogger(__name__) -_EDGE_TOLERANCE_M = 0.02 +_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M TiltRange = tuple[float, float, float | None] diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 5923171c4..040d79122 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -24,6 +24,7 @@ from common_util.common_util_cross_berm import ( BermSpec, ) from common_util.common_util_route_profile import design_elevation_from_longitudinal +from common_util.common_util_structure_station import EDGE_TOLERANCE_M from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M logger = logging.getLogger(__name__) @@ -125,8 +126,8 @@ def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float: return 0.0 -# 범위 끝 허용오차(m) — 브라우저 짝(`common_util_structure_pavement` · `_culvert_sets`)과 같은 값. -_PAVED_EDGE_TOLERANCE_M = 0.02 +# 범위 끝 허용오차(m) — 같은 자리 거리 한 곳(323-7) +_PAVED_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool: diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 74e608c56..6c36b1dfe 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -93,6 +93,8 @@ export interface RevetmentLayout { chainageM: number; /** 벽 구조물 id — 그린 벽 신원표(323-5). */ structureId: string | null; + /** 종류 이름(옹벽 · 흙막이 · 돌쌓기 …) — 툴팁 글(323-7 · 「독립 기슭막이」로 뜨던 것). */ + name: string; /** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */ requestedTiers: number; /** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */ @@ -319,6 +321,7 @@ export function computeRevetmentLayout( heightM: pureHeight, chainageM: Number(section.chainage_m) || 0, structureId: spec.structure_id ?? null, + name: spec.name || "벽", requestedTiers, top: jt, walls, @@ -352,7 +355,7 @@ export function appendRevetmentOverlay( segment.points, x, y, - `독립 기슭막이 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + + `${layout.name} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + (segment.overLimit ? " (법정 5m 이상 — 단 추가 검토)" : " (법정 5m 이내)") + ` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`, ); @@ -366,7 +369,7 @@ export function appendRevetmentOverlay( layout.walls.forEach((wall, index) => { const keyId = index === 0 ? "own" : `own-extra${index - 1}`; const tooltip = - `독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` + + `${layout.name} ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` + `(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사))` + (wall.floatGapM > 0.01 ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도` diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py index 2d34ebbb1..d8f3038f4 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py @@ -26,6 +26,7 @@ from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_stru from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points from common_util.common_util_node_bundle import run_bundle_json +from common_util.common_util_structure_station import PIPE_MATCH_M # 같은 자리 폭 한 곳(323-7) logger = logging.getLogger(__name__) @@ -33,8 +34,6 @@ ROOT = Path(__file__).resolve().parents[1] _OPTIONS_BUNDLE = ROOT / "config" / "b07_note_node" / "B07_DesignDetail_Note_Options_Node.js" _OPTIONS_NPM = "build:b07-note" -# TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭 -PIPE_MATCH_M = 0.51 _LAYER_ID = "기본" _FONT = 2.0 _LINE = 3.0 diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Check.py b/M02_MasterTemplete/StructOptions/M02_SO_Check.py index a8a9c9b70..fd56bf337 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Check.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Check.py @@ -111,7 +111,8 @@ def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str] continue if row["shape"] == "none" and row["station"] != "none": out.append(f"「{row['name']}」 형상이 「없음」이면 횡단도도 「안 만듦」") - kept = _hidden_in(before or doc, t["type_id"]) # 앞 판 없음 = 새로 숨긴 것 없음 + # 앞 판 없음 = 새로 숨긴 것 없음 · 복사한 종류 = 본보기가 앞 판에서 숨긴 칸도 그대로(323-7) + kept = [k for tid in (t["type_id"], row["base"]) for k in _hidden_in(before or doc, tid)] hidden = [key for key in _hidden_in(doc, t["type_id"]) if key not in kept] # 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김 keys = { @@ -144,10 +145,36 @@ def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str] return out +def _template_errors(doc: dict[str, Any]) -> list[str]: + """본보기(복사한 종류 · 323-7) — 원본(구조물 정본 · 본보기 아닌 종류) · 같은 군 · 자리꼴 · + 정본(TS 짝).""" + out: list[str] = [] + kinds = doc.get("종류") or {} + now = {t["type_id"]: t for t in doc.get("types") or []} + for tid, row in kinds.items(): + src = (row or {}).get("본보기") if isinstance(row, dict) else None + if src is None or tid not in now: + continue + t, base = now[tid], now.get(src) + name = t.get("name") or tid + if base is None or src == tid or (kinds.get(src) or {}).get("본보기"): + out.append(f"「{name}」 본보기 「{src}」 — 원본 종류여야 함") + continue + same = all( + t.get(k) == base.get(k) for k in ("group", "placement", "managed_by") + ) and not base.get("managed_by") + if not same: + out.append( + f"「{name}」 본보기 「{base.get('name') or src}」 와 군 · 자리꼴 · 정본이 같아야 함" + ) + return out + + def _fixed_errors(doc: dict[str, Any], before: dict[str, Any]) -> list[str]: """앞 판과 비교 — 만든 뒤 고정.""" out: list[str] = [] now = {t["type_id"]: t for t in doc.get("types") or []} + base_of = lambda d, tid: ((d.get("종류") or {}).get(tid) or {}).get("본보기") # noqa: E731 for old in before.get("types") or []: tid = old["type_id"] name = old.get("name") or tid @@ -159,6 +186,7 @@ def _fixed_errors(doc: dict[str, Any], before: dict[str, Any]) -> list[str]: ("자리꼴", old.get("placement"), t.get("placement")), ("정본", old.get("managed_by"), t.get("managed_by")), ("군 글자", old.get("group"), t.get("group")), + ("본보기", base_of(before, tid), base_of(doc, tid)), ) for what, a, b in fixed: if a != b: @@ -180,4 +208,5 @@ def catalog_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[s shape = _shape_errors(doc, known) if shape: return shape - return _row_errors(doc, before) + _fixed_errors(doc, before) if before else [] + templates = _template_errors(doc) + return templates + (_row_errors(doc, before) + _fixed_errors(doc, before) if before else []) diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts index 2d5636210..3d6479ad2 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -29,12 +29,13 @@ import { import { buildOptionsBlock } from "./M02_SO_Def_Options"; import { buildRulesBlock } from "./M02_SO_Def_Rules"; import { buildSpecBlock } from "./M02_SO_Def_Spec"; -import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe"; +import { specFor } from "../../A00_Common/design/design_structure_pipe"; import { catalogProblems, type CheckDef } from "./M02_SO_Def_Check"; import { buildParts } from "./M02_SO_Def_Parts"; import { differs, setReflect, tidy } from "./M02_SO_Def_Reflect"; import { buildReflectBlock, buildTable } from "./M02_SO_Def_Table"; -import { checkBox, textBox } from "./M02_SO_Def_Ui"; +import { checkBox, pickBox, textBox } from "./M02_SO_Def_Ui"; +import { addFromTemplate, templates } from "./M02_SO_Def_Add"; import { buildGroupBlock } from "./M02_SO_Groups"; import { to } from "./M02_SO_Text"; @@ -116,6 +117,21 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { const d = shown(); const edit = canEdit(); const parts: HTMLElement[] = []; + // 종류 더하기(323-7) — 본보기 고름 + 단추 · 새 종류를 골라 둠(이름 · 반영은 오른쪽에서 고침) + if (edit) { + const pairs = templates(d); + let pick = pairs.find(([key]) => key === current)?.[0] ?? pairs[0]?.[0] ?? ""; + const box = pickBox(pairs, pick, (v) => (pick = v), { edit, attr: "data-sod-add-from" }); + box.title = to("AddKindNote"); + const add = smallButton(to("AddKind"), () => { + const key = addFromTemplate(d, pick); + if (!key) return; + current = key; + changed(); + }); + add.setAttribute("data-sod-add", ""); + parts.push(el("div", { className: "m02-sod__family", children: [box, add] })); + } const families = Object.keys(d.구조물군); families.forEach((g, gi) => { parts.push( @@ -194,7 +210,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { const edit = canEdit(); const extra = (): NonNullable => (d.종류[type.type_id] ??= {}); // 이름만 고침(PLAN 52-11 · 52-12) — 명세 종류 = 명세 나무 · 나머지 = 정의 그룹 나무 - const spec = STRUCTURE_SPECS[type.type_id]; + const spec = specFor(type.type_id); detail.replaceChildren( el("div", { className: "m02-sod__kindhead", diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Add.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Add.ts new file mode 100644 index 000000000..ab0621376 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Add.ts @@ -0,0 +1,45 @@ +/* ============================================================================= + * M02_SO_Def_Add.ts + * 종류 더하기(PLAN 323-7) — 본보기(구조물 정본 원본 종류)를 그대로 복사한 새 종류. + * + * 키 자동(`{본보기}_xxxxxx` · 만든 뒤 안 바뀜) · 이름 「{본보기} 2」(겹치면 3 …) · 군 · 자리꼴 · 옵션 · 표시 · 그룹 · + * 배치 · 규칙 · 종류 덧칸(동작 · 이름 표) · 반영 그대로 · `종류.{새}.본보기 = 원본` — 구조물 표가 본보기 처음값을 + * 이어받고(`resolveCatalog` · 일곱 갈래 = 목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D · 자리꼴), 손코딩 동작 · + * 명세도 본보기 키로 고름(`baseType`). 관 지점 정본 종류는 본보기에서 뺌(관 시설 폼 · 정본 목록이 따로). + * ========================================================================== */ + +import { clone, newKey, type StructDef } from "./M02_SO_Def_Model"; + +/** 본보기로 고를 수 있는 종류 — 구조물 정본 · 원본(본보기 아님) · [키, 이름] (정의 차례). */ +export const templates = (def: StructDef): Array<[string, string]> => + def.types + .filter((t) => !t.managed_by && !def.종류[t.type_id]?.본보기) + .map((t) => [t.type_id, t.name] as [string, string]); + +/** 본보기를 복사해 그 뒤에 새 종류를 끼움 — 새 키(못 하면 null). */ +export function addFromTemplate(def: StructDef, sourceId: string): string | null { + const index = def.types.findIndex((t) => t.type_id === sourceId); + const src = def.types[index]; + if (!src || src.managed_by || def.종류[sourceId]?.본보기) return null; + const key = newKey( + `${sourceId}_`, + def.types.map((t) => t.type_id), + ); + const names = new Set(def.types.map((t) => t.name)); + let n = 2; + while (names.has(`${src.name} ${n}`)) n += 1; + const copy = clone(src); + copy.type_id = key; + copy.name = `${src.name} ${n}`; + delete copy.drawing_views; // 안 읽는 칸(323-7) + def.types.splice(index + 1, 0, copy); + def.종류[key] = { ...clone(def.종류[sourceId] ?? {}), 본보기: sourceId }; + for (const table of [def.표시, def.그룹, def.배치] as Array>) + if (table[sourceId] !== undefined) table[key] = clone(table[sourceId]); + def.규칙.push( + ...def.규칙.filter((r) => r.종류 === sourceId).map((r) => ({ ...clone(r), 종류: key })), + ); + const reflect = (def as { 반영?: Record }).반영; + if (reflect?.[sourceId] !== undefined) reflect[key] = clone(reflect[sourceId]); + return key; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts index f843d0031..fa88aae0d 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts @@ -126,7 +126,8 @@ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { if (!row) continue; if (row.shape === "none" && row.station !== "none") out.push(`「${row.name}」 형상이 「없음」이면 횡단도도 「안 만듦」`); - const kept = hiddenIn(before ?? doc, t.type_id); // 앞 판 없음 = 새로 숨긴 것 없음 + // 앞 판 없음 = 새로 숨긴 것 없음 · 복사한 종류 = 본보기가 앞 판에서 숨긴 칸도 그대로(323-7) + const kept = [t.type_id, row.base].flatMap((id) => hiddenIn(before ?? doc, id)); const hidden = hiddenIn(doc, t.type_id).filter((key) => !kept.includes(key)); // 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김 const keys = new Set( @@ -161,9 +162,35 @@ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { } /** 앞 판과 비교 — 만든 뒤 고정. */ +/** 본보기(복사한 종류 · 323-7) — 원본(구조물 정본 · 본보기 아닌 종류) · 같은 군 · 자리꼴 · 정본(파이썬 짝). */ +function templateErrors(doc: CheckDef): string[] { + const out: string[] = []; + const kinds = doc.종류 ?? {}; + const now = new Map((doc.types ?? []).map((t) => [t.type_id, t])); + for (const [tid, row] of Object.entries(kinds)) { + const src = row?.본보기; + const t = now.get(tid); + if (src == null || !t) continue; + const base = now.get(src); + const name = t.name || tid; + if (!base || src === tid || kinds[src]?.본보기) { + out.push(`「${name}」 본보기 「${src}」 — 원본 종류여야 함`); + continue; + } + const same = + (["group", "placement", "managed_by"] as const).every( + (k) => (t[k] ?? null) === (base[k] ?? null), + ) && !base.managed_by; + if (!same) + out.push(`「${name}」 본보기 「${base.name || src}」 와 군 · 자리꼴 · 정본이 같아야 함`); + } + return out; +} + function fixedErrors(doc: CheckDef, before: CheckDef): string[] { const out: string[] = []; const now = new Map((doc.types ?? []).map((t) => [t.type_id, t])); + const baseOf = (d: CheckDef, tid: string) => d.종류?.[tid]?.본보기 ?? null; for (const old of before.types ?? []) { const t = now.get(old.type_id); const name = old.name ?? old.type_id; @@ -175,6 +202,7 @@ function fixedErrors(doc: CheckDef, before: CheckDef): string[] { ["자리꼴", old.placement, t.placement], ["정본", old.managed_by ?? null, t.managed_by ?? null], ["군 글자", old.group, t.group], + ["본보기", baseOf(before, old.type_id), baseOf(doc, old.type_id)], ]; for (const [what, a, b] of fixed) if (a !== b) out.push(`「${name}」 ${what} — 만든 뒤 고정`); const options = new Map((t.options ?? []).map((o) => [o.key, o])); @@ -195,5 +223,9 @@ export function catalogProblems(doc: CheckDef, before: CheckDef | null): string[ const known = new Set((doc.types ?? []).map((t) => t.type_id)); const shape = shapeErrors(doc, known); if (shape.length) return shape; - return before ? [...rowErrors(doc, before), ...fixedErrors(doc, before)] : []; + const templates = templateErrors(doc); + return [ + ...templates, + ...(before ? [...rowErrors(doc, before), ...fixedErrors(doc, before)] : []), + ]; } diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts index 2cb6355bb..b1bee5190 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts @@ -58,7 +58,11 @@ export interface StructDef { 동작: Record; types: TypeDef[]; /** 이름 = 구조물 명세 이름 바꿈 표(PLAN 52 · 키 `{컨테이너}[.{항목}[={값}]]`) */ - 종류: Record }>; + /** 본보기 = 복사한 종류의 원본 키(323-7 · 만든 뒤 고정 · 처음값 · 손코딩 동작을 이어받음). */ + 종류: Record< + string, + { 기준측점?: boolean; 동작?: string[]; 이름?: Record; 본보기?: string } + >; 표시: Record>; 그룹: Record; 배치: Record>; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py index 4f475b329..157e0eadf 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py @@ -66,7 +66,9 @@ def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, An groups = {} for row in doc.get("types") if isinstance(doc.get("types"), list) else []: try: - t = schema.StructureType.model_validate(row) + t = schema.StructureType.model_validate( + schema.without_retired(row) if isinstance(row, dict) else row + ) except ValidationError as e: errors.append( f"종류 「{row.get('type_id') if isinstance(row, dict) else row}」 꼴 — {e}" @@ -111,8 +113,11 @@ def _kind_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[s continue if name == "이름" and _names_ok(value): continue + if name == "본보기" and isinstance(value, str) and value in keys: + continue # 복사한 종류의 원본(323-7) — 같은 군 등은 `_template_errors` errors.append( - f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들 · 이름 = 글 묶음)" + f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들 · " + "이름 = 글 묶음 · 본보기 = 있는 종류 키)" ) return errors @@ -221,6 +226,13 @@ def save(doc: Any, version: Any) -> dict[str, Any]: """정의 통째로 고쳐 씀(시스템 관리자) — 돌려주는 것 = 새 정의.""" if not isinstance(doc, dict): raise store.StoreError(400, "정의는 묶음이어야 함") + # 옛 칸(drawing_views)은 [저장] 때 빠짐(323-7 · 아무도 안 읽음) + types = doc.get("types") + if isinstance(types, list): + doc = { + **doc, + "types": [schema.without_retired(t) if isinstance(t, dict) else t for t in types], + } return _commit(lambda _: dict(doc), version) diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index 097e3e226..a4ec6adbc 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -21,6 +21,11 @@ const TEXT = { GroupName: ["그룹 이름", "Group name"], GroupNew: ["새 그룹 {value}", "New group {value}"], GroupUp: ["위로", "Up"], + AddKind: ["종류 더하기", "Add type"], + AddKindNote: [ + "본보기 종류를 그대로 복사한 새 종류(이름 「본보기 2」) — 옵션 · 그룹 · 반영 · 그리는 법이 같음 · 이름 · 반영은 고쳐서 [저장]", + "New type copied from the template (name “template 2”) — same options, groups, reflect and drawing; edit name / reflect, then [Save]", + ], GroupDown: ["아래로", "Down"], GroupDel: ["지우기", "Delete"], GroupDelAsk: [ diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 14c900656..144ea2fef 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -66,13 +66,13 @@ PIPE_FACILITY_FORD_BRIDGE = "ford_bridge" # 세월교 # 독립 기슭막이(2026-08-28 사용자) — 배관 없이 성토 사면에 세우는 벽. 배관 세트 경로를 # 그대로 태우되 관을 숨긴다(hidden_pipe). 수량은 관 정보를 빼고 벽만 센다. PIPE_FACILITY_REVET = "revetment" # 독립 기슭막이(관 숨김) -_KNOWN_FACILITIES = ( - PIPE_FACILITY_PIPE, - PIPE_FACILITY_BOX, - PIPE_FACILITY_FORD_PAVEMENT, - PIPE_FACILITY_FORD_BRIDGE, - PIPE_FACILITY_REVET, -) + + +def _known_facility(facility: str) -> bool: + """관 지점 정본 종류인가 = 구조물 표 정본(323-7 · 옛 손 목록 걷음 · 모르는 종류 = 배관).""" + from common_util.common_util_structure_catalog import catalog_row + + return (catalog_row(facility) or {}).get("owner") == "pipe_points" @dataclass @@ -355,7 +355,7 @@ def parse_pipe_points(values: Any) -> list[PipePoint]: PipePoint( chainage_m=float(chainage), source=source if source in _KNOWN_SOURCES else PIPE_SOURCE_USER, - facility=facility if facility in _KNOWN_FACILITIES else PIPE_FACILITY_PIPE, + facility=facility if _known_facility(facility) else PIPE_FACILITY_PIPE, start_m=start, end_m=end, x=float(raw_x) if isinstance(raw_x, (int, float)) else None, diff --git a/common_util/common_util_structure_catalog.py b/common_util/common_util_structure_catalog.py index 3ed7ea502..403168711 100644 --- a/common_util/common_util_structure_catalog.py +++ b/common_util/common_util_structure_catalog.py @@ -41,7 +41,8 @@ def resolve_catalog( rows: dict[str, dict[str, Any]] = {} for order, item in enumerate(types): type_id = item["type_id"] - base = start.get(type_id) or {} + root = ((definition.get("종류") or {}).get(type_id) or {}).get("본보기") or type_id + base = start.get(root) or {} # 복사한 종류 = 본보기 처음값(323-7) mine = reflect.get(type_id) or {} def part(branch: str, base=base, mine=mine) -> str: @@ -72,6 +73,7 @@ def resolve_catalog( "shapeOpts": {**(base.get("형상칸") or {}), **(mine.get("형상칸") or {})}, "model3d": part("3D"), "color": (item.get("style") or {}).get("color") or None, + "base": root, } return rows @@ -94,6 +96,12 @@ def catalog_row(type_id: str) -> dict[str, Any] | None: return catalog().get(type_id) +def base_type(type_id: str) -> str: + """손코딩 동작 · 명세를 고를 종류 키 = 본보기(복사한 종류 · 323-7) — TS `baseType` 짝.""" + row = catalog_row(type_id) + return row["base"] if row else type_id + + def type_ids_with(**match: str) -> set[str]: """표에서 칸 값이 맞는 종류들 — 예 `type_ids_with(shape="pavement")`.""" return {key for key, row in catalog().items() if all(row[k] == v for k, v in match.items())} diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts index d416affff..c8c67f05b 100644 --- a/common_util/common_util_structure_catalog.ts +++ b/common_util/common_util_structure_catalog.ts @@ -34,6 +34,8 @@ export interface CatalogRow { /** 3D 부품 — none · pipe · box · ford · chute · wall · surface. */ model3d: string; color: string | null; + /** 본보기 종류 키(323-7 · M02 종류 더하기) — 처음값 · 손코딩 동작을 이 종류에서 이어받음 · 원본이면 자기. */ + base: string; } type Branch = "횡단도" | "카드이름" | "형상" | "3D"; @@ -57,6 +59,8 @@ export interface CatalogDefinition { }>; 반영?: Record; 부품?: Record; + /** 종류 덧칸 — 본보기(복사한 종류의 원본 키 · 323-7). */ + 종류?: Record; } interface Parts { @@ -91,7 +95,8 @@ export function resolveCatalog( const rows = new Map(); types.forEach((item, typeOrder) => { const id = item.type_id; - const base = start[id] ?? {}; + const root = definition?.종류?.[id]?.본보기 ?? id; // 복사한 종류 = 본보기 처음값(323-7) + const base = start[root] ?? {}; const mine = definition?.반영?.[id] ?? {}; const part = (branch: Branch): string => { const value = mine[branch] ?? base[branch] ?? OFF[branch]; @@ -122,6 +127,7 @@ export function resolveCatalog( shapeOpts: { ...(base.형상칸 ?? {}), ...(mine.형상칸 ?? {}) }, model3d: part("3D"), color: ("style" in item && item.style?.color) || null, + base: root, }); }); return rows; @@ -147,6 +153,9 @@ export function useCatalogDefinition(definition: CatalogDefinition | null): void /** 종류 한 줄 — 표에 없는 종류면 undefined. */ export const catalogRow = (typeId: string): CatalogRow | undefined => current.get(typeId); +/** 손코딩 동작 · 명세를 고를 종류 키 = 본보기(복사한 종류 · 323-7) · 원본이면 자기 · 파이썬 `base_type` 짝. */ +export const baseType = (typeId: string): string => current.get(typeId)?.base ?? typeId; + /** 고르기 목록에 보일 종류인가 — B05 = both · B06 = both + b06(표에 없는 종류는 보임). */ export const listPick = (page: "b05" | "b06") => diff --git a/common_util/common_util_structure_pavement.py b/common_util/common_util_structure_pavement.py index 11411dde6..41fcffdbe 100644 --- a/common_util/common_util_structure_pavement.py +++ b/common_util/common_util_structure_pavement.py @@ -13,10 +13,12 @@ import math from pathlib import Path from typing import Any +from common_util.common_util_structure_station import EDGE_TOLERANCE_M + logger = logging.getLogger(__name__) -# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — C군 벽(`common_util_structure_walls`)과 같은 값. -_EDGE_TOLERANCE_M = 0.02 +# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 같은 자리 거리 한 곳(323-7) +_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M def _num(value: Any) -> float | None: diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index 898de975f..ef0370865 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -72,8 +72,15 @@ def snap_candidates( # ── 측점 계획 한 벌(322-3 · TS `planStations` 짝 · 거울 = test_322_3_plan_mirror.py) ── -#: 같은 측점으로 볼 거리(m) — TS `SAME_STATION_M` 짝 +# ── 같은 자리 거리 한 곳(323-7 · 파이썬은 여기서 가져다 씀 · TS 짝은 이름이 같은 상수) ── +#: 같은 측점으로 볼 거리(m) — 측점 계획 · 반올림 차이만 · TS `SAME_STATION_M` 짝 SAME_STATION_M = 0.005 +#: 구간 끝 · 관 세트가 걸치는 측점을 같은 자리로 볼 폭(m) — 정본이 누가거리를 0.01 m 로 끊어 씀 · +#: TS `EDGE_TOLERANCE_M`(벽 · 포장 · 맹암거) · `CHAINAGE_TOLERANCE_M`(관 세트) 짝 +EDGE_TOLERANCE_M = 0.02 +#: 관 · 구조물 자리와 측점 · 고름을 같은 자리로 볼 폭(m) — 정수 m 기준 측점 · 옛 소수 자료 · TS +#: `design_selection.PIPE_MATCH_M` 짝 +PIPE_MATCH_M = 0.51 def _empty(value: object) -> bool: diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 9cec8d6b5..9d14c770d 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -27,6 +27,11 @@ import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catal const EPS = 1e-6; +/* 같은 자리 거리(323-7 · 뜻마다 하나 · 파이썬은 `common_util_structure_station.py` 한 곳에서 가져다 씀): + * SAME_STATION_M 0.005 = 같은 측점(반올림 차이) — 여기 + * EDGE_TOLERANCE_M · CHAINAGE_TOLERANCE_M 0.02 = 구간 끝 · 관 세트가 걸치는 측점(정본 0.01 m 끊음) — 벽 · + * 포장 · 맹암거 · 관 세트 모듈(거울 시험이 tsc ESM 으로 따로 엮어 각자 둠 · 값은 파이썬 `EDGE_TOLERANCE_M`) + * PIPE_MATCH_M 0.51 = 관 · 구조물 자리와 측점 · 고름(정수 m · 옛 소수) — `design_selection` */ /** 같은 측점으로 볼 거리(m) — 서버 측점 만들기의 격자 붙임 폭(`Engine_Sections_Core`)과 같음. */ export const SAME_STATION_M = 0.005; diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts index 1cdc8ca17..5edcbbd67 100644 --- a/common_util/common_util_structure_walls.ts +++ b/common_util/common_util_structure_walls.ts @@ -12,7 +12,7 @@ * ========================================================================== */ // `.js` = 짝 거울 시험이 tsc → Node ESM 으로 이 파일만 엮음(확장자 있어야 찾음 · vite 는 .ts 로 풂) -import { catalogRow } from "./common_util_structure_catalog.js"; +import { baseType, catalogRow } from "./common_util_structure_catalog.js"; /** 구조물 정본 한 건(필요한 칸만). `structures.json` 과 같은 이름을 쓴다. */ export interface WallStructureInput { @@ -89,7 +89,11 @@ export function tierExtras(typeId: string, options: Record): Wa ), ].sort((a, b) => a - b); return ns.map((n) => ({ - form: typeId === "retaining_wall" ? "콘크리트" : (options[`tier_${n}_form`] as string) || null, + // 옹벽(본보기 포함 · 323-7) 추가 단 = 콘크리트 + form: + baseType(typeId) === "retaining_wall" + ? "콘크리트" + : (options[`tier_${n}_form`] as string) || null, height_m: num(options[`tier_${n}_height`]), })); } diff --git a/resources/tester/helper_116_right_click_add.cjs b/resources/tester/helper_116_right_click_add.cjs index 1853a63c6..8ee4931ea 100644 --- a/resources/tester/helper_116_right_click_add.cjs +++ b/resources/tester/helper_116_right_click_add.cjs @@ -49,7 +49,17 @@ const { addsAtOnce, finishAdd } = loadTs( const { loadForm } = loadTs(path.join(B05, "B05_Profile_UI_Structures_Panel_Commit.ts")); const entry = (required, value) => ({ required, isEmpty: () => value === "" }); -const type = (type_id, managed_by = null) => ({ type_id, group: "C", managed_by }); +// 동작 = 정의 종류 덧칸(앱의 `withDefinition` 과 같음 · 323-7 종류 키 대신 동작 이름) +const DEFINED = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const behaviors = (typeId) => DEFINED.종류?.[typeId]?.동작 ?? []; +const type = (type_id, managed_by = null) => ({ + type_id, + group: "C", + managed_by, + behaviors: behaviors(type_id), +}); /** 목록 알림 → 페이지가 임시 id 를 붙여 패널 배열 안을 갈아 채우는 것(`withLocalIds` · `setStructures`). */ function fakeCommit(structures, adds) { diff --git a/resources/tester/helper_126_ford_fill.cjs b/resources/tester/helper_126_ford_fill.cjs index 1fe3f5984..1c8d78d64 100644 --- a/resources/tester/helper_126_ford_fill.cjs +++ b/resources/tester/helper_126_ford_fill.cjs @@ -43,7 +43,16 @@ const { fordHeightM } = loadTs( path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts"), ); -const type = (type_id) => ({ type_id, group: "A", managed_by: "pipe_points" }); +// 동작 = 정의 종류 덧칸(앱의 `withDefinition` 과 같음 · 323-7 종류 키 대신 동작 이름) +const DEFINED = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const type = (type_id) => ({ + type_id, + group: "A", + managed_by: "pipe_points", + behaviors: DEFINED.종류?.[type_id]?.동작 ?? [], +}); /** 우클릭 → 바로 추가 — 관 지점 정본에 한 번 넘기고 그 측점 관을 고름 · 알림 한 줄. */ async function rightClick(typeId) { @@ -116,7 +125,14 @@ function respond(points, basins, pending) { once: { fordBridge: addsAtOnce(type("ford_bridge"), []), fordPavement: addsAtOnce(type("ford_pavement"), []), - pavement: addsAtOnce({ type_id: "pavement_concrete", managed_by: null }, []), + pavement: addsAtOnce( + { + type_id: "pavement_concrete", + managed_by: null, + behaviors: DEFINED.종류?.pavement_concrete?.동작 ?? [], + }, + [], + ), }, bridge: await rightClick("ford_bridge"), paveRc: await rightClick("ford_pavement"), diff --git a/resources/tester/helper_166_1_note_options.cjs b/resources/tester/helper_166_1_note_options.cjs index 725dba4cc..2ac2f22df 100644 --- a/resources/tester/helper_166_1_note_options.cjs +++ b/resources/tester/helper_166_1_note_options.cjs @@ -4,6 +4,7 @@ */ const fs = require("fs"); const path = require("path"); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); @@ -18,6 +19,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; // 구조물 표(본보기 · 323-7) if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); return require(request); }; diff --git a/resources/tester/helper_323_7_new_type.cjs b/resources/tester/helper_323_7_new_type.cjs new file mode 100644 index 000000000..6ad52de98 --- /dev/null +++ b/resources/tester/helper_323_7_new_type.cjs @@ -0,0 +1,104 @@ +/* PLAN 323-7 종류 더하기 헬퍼 — 진짜 `M02_SO_Def_Add.ts`(본보기 복사) · 구조물 표 · 측점 계획 · 형상 얹기 · 벽 단 · + * 명세 고르기 · M02 검사를 그 자리에서 트랜스파일해 Node 로 돌림(마스터는 읽기만 · 사본에서 더함) · 결과 = JSON. + * 짝 = `test_323_7_new_type.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) => { + if (spec.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); + 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 C = (name) => path.join(ROOT, "common_util", `${name}.ts`); +const M = (name) => path.join(ROOT, "M02_MasterTemplete", "StructOptions", `${name}.ts`); +const master = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const def = JSON.parse(JSON.stringify(master)); +const add = load(M("M02_SO_Def_Add")); +const catalog = load(C("common_util_structure_catalog")); +const station = load(C("common_util_structure_station")); +const attach = load(C("common_util_structure_attach")); +const walls = load(C("common_util_structure_walls")); +const pipeSpecs = load(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts")); +const check = load(M("M02_SO_Def_Check")); + +const out = {}; +out.templates = add.templates(def).map(([key]) => key); +out.pipeRefused = add.addFromTemplate(def, "pipe"); +const wall = add.addFromTemplate(def, "retaining_wall"); +const pave = add.addFromTemplate(def, "pavement_concrete"); +out.keys = [wall, pave]; +const types = def.types.map((t) => ({ managed_by: null, enabled: true, ...t })); +catalog.useCatalogDefinition({ ...def, types }); +const BRANCHES = [ + "list", + "station", + "title", + "shape", + "shapeOpts", + "model3d", + "placement", + "owner", +]; +const pick = (row) => Object.fromEntries(BRANCHES.map((k) => [k, row[k]])); +out.rows = { + wall: [pick(catalog.catalogRow("retaining_wall")), pick(catalog.catalogRow(wall))], + pave: [pick(catalog.catalogRow("pavement_concrete")), pick(catalog.catalogRow(pave))], +}; +out.base = [catalog.baseType(wall), catalog.baseType("retaining_wall")]; +out.names = [ + types.find((t) => t.type_id === wall).name, + types.find((t) => t.type_id === pave).name, +]; +const structure = { + structure_id: "n1", + type_id: wall, + placement: "interval", + start_m: 40, + chainage_m: 50, + end_m: 60, + options: { height_m: 2, tier_1_height: 1 }, +}; +out.plan = station.planStations([], [structure], types).map((s) => [s.chainage_m, s.title]); +const sections = [40, 50, 60].map((at) => ({ chainage_m: at })); +attach.attachShapes(sections, [], [structure], types); +out.attached = sections.map((s) => [s.revetment?.type_id ?? null, s.revetment?.name ?? null]); +out.tier = walls.tierExtras(wall, { tier_1_height: 1 }); +out.spec = + !!pipeSpecs.specFor(pave) && pipeSpecs.specFor(pave) === pipeSpecs.specFor("pavement_concrete"); +out.problems = check.catalogProblems(def, master); +// 본보기 바꿈 · 복사본의 복사 = 거절(만든 뒤 고정 · 원본만) +const bad = JSON.parse(JSON.stringify(def)); +bad.종류[wall].본보기 = "soil_guard"; +out.badBase = check.catalogProblems(bad, def).length > 0; +const chain = JSON.parse(JSON.stringify(def)); +chain.종류[pave].본보기 = wall; +out.chain = check.catalogProblems(chain, null).length > 0; +out.def = def; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_54_14_balloon_lines.cjs b/resources/tester/helper_54_14_balloon_lines.cjs index 5816c7935..ba749b1c2 100644 --- a/resources/tester/helper_54_14_balloon_lines.cjs +++ b/resources/tester/helper_54_14_balloon_lines.cjs @@ -5,6 +5,7 @@ */ const fs = require("fs"); const path = require("path"); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); @@ -19,6 +20,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; // 구조물 표(본보기 · 323-7) if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); return require(request); }; diff --git a/resources/tester/helper_m02_so_def_spec.cjs b/resources/tester/helper_m02_so_def_spec.cjs index 6634b447e..a3c167ce4 100644 --- a/resources/tester/helper_m02_so_def_spec.cjs +++ b/resources/tester/helper_m02_so_def_spec.cjs @@ -3,6 +3,7 @@ * 정의는 진짜 `구조물정의.json` 을 읽어 메모리 사본만 고침(파일 쓰기 없음). */ const fs = require("fs"); const path = require("path"); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); @@ -16,9 +17,11 @@ const load = (...parts) => { }).outputText; const box = (cache[file] = { exports: {} }); const near = (id) => - id.startsWith(".") - ? load(path.relative(ROOT, path.resolve(path.dirname(file), `${id}.ts`))) - : require(id); + CATALOG.isCatalogRequest(id) // 구조물 표(본보기 · 323-7) + ? CATALOG + : id.startsWith(".") + ? load(path.relative(ROOT, path.resolve(path.dirname(file), `${id}.ts`))) + : require(id); new Function("exports", "module", "require", js)(box.exports, box, near); return box.exports; }; diff --git a/resources/tester/test_178_3_slope_tiers.py b/resources/tester/test_178_3_slope_tiers.py index 85fc6bbdf..4e3831625 100644 --- a/resources/tester/test_178_3_slope_tiers.py +++ b/resources/tester/test_178_3_slope_tiers.py @@ -128,7 +128,8 @@ def test_횡단_형상_단_수_단마다_형태_높이(out): def test_배선(): spec = _read("B05_Profile", "B05_Profile_UI_Structures_Spec.ts") - assert "STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id]" in spec + # 명세 · 덧붙이 명세 = 본보기 키로(323-7 · 복사한 종류도 같은 상자) + assert "specFor(type.type_id) ?? ADDON_SPECS[baseType(type.type_id)]" in spec walls = _read("common_util", "common_util_structure_walls.ts") assert "파이썬 `tier_extras` 짝" in walls engine = _read("B06_Section", "B06_Section_Engine_Structures_Wall.py") diff --git a/resources/tester/test_322_0_ghost.py b/resources/tester/test_322_0_ghost.py index 68e3217df..b0e8749f0 100644 --- a/resources/tester/test_322_0_ghost.py +++ b/resources/tester/test_322_0_ghost.py @@ -1,7 +1,8 @@ -"""PLAN 322-0 ② — 「기타」 유령 길 막기: 종류 이름을 바꾼 뒤에도 옛 이름 측점을 구조물로 되옮기지 않음. +"""PLAN 322-0 ② → 323-7 — 「기타」 유령 길 막기: 종류 이름을 바꾼 뒤에도 옛 이름 측점을 안 되옮김. -정의 사본(임시 폴더 · 마스터 안 고침)에서 종류 이름을 바꾸고 앞 판을 `구조물정의_이력/판_N.json` 으로 둠 → -옛 이름 · 새 이름 측점 모두 이관 0건. 옛 꼴 자유 글(기성막이 · 대피로 폭 · 임의 이름)은 종전대로 옮김. +정의 사본(임시 폴더 · 마스터 안 고침)에서 종류 이름을 바꿈 → 지금 관 · 구조물이 심는 자리(측점 +계획)의 측점은 옛 이름 · 새 이름 모두 이관 0건(323-7 · 자리로 가름 · 지난 판 이름 되짚기 걷음). +옛 꼴 자유 글(기성막이 · 대피로 폭 · 임의 이름)은 심는 자리가 아니면 종전대로 옮김. """ from __future__ import annotations @@ -37,23 +38,25 @@ def renamed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: return old -def _moved(label: str) -> int: - plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": label}]) +def _moved(label: str, planted: tuple[float, ...] = ()) -> int: + plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": label}], planted) return len(plan.structures) + len(plan.pipe_facilities) -def test_old_and_new_names_are_not_migrated(renamed: dict) -> None: +def test_old_and_new_names_at_planted_spots_are_not_migrated(renamed: dict) -> None: + # 323-7 — 자리로 가름(지금 관 · 구조물이 심는 자리) · 이름을 바꿔도 자리는 그대로 assert schema.structure_type_map()["box_culvert"].name == "박스암거" # 사본을 읽음 for type_id, name in RENAMED.items(): old_name = next(t["name"] for t in renamed["types"] if t["type_id"] == type_id) - assert _moved(old_name) == 0, old_name - assert _moved(name) == 0, name + assert _moved(old_name, (120.0,)) == 0, old_name + assert _moved(name, (120.0,)) == 0, name + assert _moved(name) == 0, name # 지금 판 이름은 자리 없이도 -def test_every_type_name_of_any_version_is_blocked(renamed: dict) -> None: - names = {t["name"] for t in renamed["types"]} | set(RENAMED.values()) - assert names <= schema.all_type_names() - assert [name for name in sorted(names) if _moved(name)] == [] +def test_history_names_are_not_read(renamed: dict) -> None: + # 옛 이름 되짚기 걷음(323-7) — 자리를 못 받은 옛 이름 = 옛 자유 글처럼 「기타」 + assert not hasattr(schema, "all_type_names") + assert _moved("노출형 횡단수로") == 1 def test_legacy_free_text_still_migrates(renamed: dict) -> None: @@ -62,12 +65,4 @@ def test_legacy_free_text_still_migrates(renamed: dict) -> None: assert [s.type_id for s in plan.structures] == ["refuge"] plan = migrate_irregular_stations([{"chainage_m": 15.0, "structure": "돌망태 보강"}]) assert [(s.type_id, s.options["name"]) for s in plan.structures] == [("etc", "돌망태 보강")] - - -def test_master_history_names_are_read() -> None: - """실제 마스터 이력(판_1 ~)의 이름도 모음 — 이력 파일은 읽기만.""" - history = MASTER.parent / schema.HISTORY_DIRNAME - names = set() - for path in history.glob("판_*.json"): - names |= {t["name"] for t in json.loads(path.read_text(encoding="utf-8"))["types"]} - assert names <= schema.all_type_names() + assert _moved("돌망태 보강", (120.004,)) == 0 # 심는 자리면 글이 무엇이든 안 옮김 diff --git a/resources/tester/test_323_7_new_type.py b/resources/tester/test_323_7_new_type.py new file mode 100644 index 000000000..3e01fb740 --- /dev/null +++ b/resources/tester/test_323_7_new_type.py @@ -0,0 +1,106 @@ +"""PLAN 323-7 종류 더하기 — 본보기 복사 한 종류가 일곱 갈래(목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D · 자리꼴) +를 본보기 그대로 그림 · 손코딩 동작 · 명세는 본보기 키로(`baseType`) · 검사는 화면 = 서버. + +마스터는 읽기만 — 사본(헬퍼가 더한 정의)을 임시 파일로 두고 서버 쪽 읽기를 그리로 돌림. +TS 쪽 = `helper_323_7_new_type.cjs`(Node). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from B05_Profile import B05_Profile_Structures_Schema as schema +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance +from B06_Section.B06_Section_Engine_Structures_Wall import wall_specs_from +from common_util import common_util_structure_catalog as catalog +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def ts() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_7_new_type.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +@pytest.fixture() +def copied(ts: dict, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + path = tmp_path / "구조물정의.json" + path.write_text(json.dumps(ts["def"], ensure_ascii=False), encoding="utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(path)) + monkeypatch.setattr(catalog, "DEFINITION_PATH", str(path), raising=False) + return ts["def"] + + +def test_only_structure_originals_are_templates(ts: dict) -> None: + assert "pipe" not in ts["templates"] and "retaining_wall" in ts["templates"] + assert ts["pipeRefused"] is None + + +def test_seven_branches_follow_template(ts: dict) -> None: + for name, (source, copy) in ts["rows"].items(): + assert copy == source, name + assert ts["base"] == ["retaining_wall", "retaining_wall"] + assert ts["names"] == ["옹벽 2", "콘크리트 포장 2"] + + +def test_new_type_draws_like_template(ts: dict) -> None: + wall = ts["keys"][0] + assert ts["plan"] == [[40, "옹벽 2"], [50, "옹벽 2"], [60, "옹벽 2"]] + assert ts["attached"] == [[wall, "옹벽 2"]] * 3 + assert ts["tier"] == [ + {"form": "콘크리트", "height_m": 1} + ] # 옹벽 추가 단 = 콘크리트(본보기 동작) + assert ts["spec"] is True # 포장 명세 = 본보기 명세 + + +def test_check_screen_equals_server(ts: dict, copied: dict) -> None: + master = json.loads(Path(definition.schema.DEFINITION_PATH).read_text(encoding="utf-8")) + assert ts["problems"] == [] + real = json.loads( + (ROOT / "resources" / "master_template" / "구조물정의.json").read_text("utf-8") + ) + assert definition.check(copied, real) == [] + assert ts["badBase"] is True and ts["chain"] is True + bad = json.loads(json.dumps(copied)) + bad["종류"][ts["keys"][0]]["본보기"] = "soil_guard" + assert any("본보기" in e for e in definition.check(bad, copied)) + assert master["types"] # 사본을 읽음 + + +def test_server_reads_copy_like_template(ts: dict, copied: dict) -> None: + wall = ts["keys"][0] + rows = catalog.resolve_catalog(copied) + keep = ("list", "station", "title", "shape", "shapeOpts", "model3d", "placement", "owner") + assert {k: rows[wall][k] for k in keep} == {k: rows["retaining_wall"][k] for k in keep} + assert rows[wall]["base"] == "retaining_wall" + item = StructureInstance.model_validate( + { + "structure_id": "n1", + "type_id": wall, + "placement": "interval", + "start_m": 40, + "chainage_m": 50, + "end_m": 60, + "options": {"height_m": 2, "tier_1_height": 1}, + } + ) + (spec,) = wall_specs_from([item]) + assert spec["name"] == "옹벽 2" and spec["extras"][0]["form"] == "콘크리트" diff --git a/resources/tester/test_b05_facility_options_merge.py b/resources/tester/test_b05_facility_options_merge.py index c1a5b5c8b..27c2d3853 100644 --- a/resources/tester/test_b05_facility_options_merge.py +++ b/resources/tester/test_b05_facility_options_merge.py @@ -110,9 +110,18 @@ def test_폼이_적는_칸이_모두_목록에_있다(merged) -> None: """폼에 칸을 늘리고 목록에 안 넣으면 여기서 잡힘.""" source = FORM_TS.read_text(encoding="utf-8") body = source[source.index("readOptions() {") :] - branches = re.split(r'current === "(\w+)"', body)[1:] - assert branches[::2] == ["pipe", "box_culvert", "ford_pavement", "ford_bridge", "revetment"] - for facility, block in zip(branches[::2], branches[1::2]): + # 폼 갈래 = 구조물 표 형상(323-7 · 종류 키 대신) → 관 지점 종류 + branches = re.split(r'shapeOf\(current\) === "(\w+)"', body)[1:] + kinds = { + "pipe_set": "pipe", + "box": "box_culvert", + "ford_pavement": "ford_pavement", + "ford": "ford_bridge", + "hidden_revet": "revetment", + } + assert branches[::2] == list(kinds) + for shape, block in zip(branches[::2], branches[1::2]): + facility = kinds[shape] written = set(re.findall(r'putNumber\(options, "(\w+)"', block)) written |= set(re.findall(r"options\.(\w+) =", block)) missing = written - set(merged["keys"][facility]) diff --git a/resources/tester/test_m02_structure_def_screen.py b/resources/tester/test_m02_structure_def_screen.py index 6ca5e6242..108ff9196 100644 --- a/resources/tester/test_m02_structure_def_screen.py +++ b/resources/tester/test_m02_structure_def_screen.py @@ -92,8 +92,11 @@ def test_화면_고침을_그대로_받아_판_더하기_1_지난_판_길(copy) assert res.status_code == 200, res.text got = res.json() assert got["판"] == doc["판"] + 1 - for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙", "types"): + for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙"): assert got[key] == sent[key], key + # 옛 칸(drawing_views)은 [저장] 때 빠짐(323-7 · 아무도 안 읽음) — 나머지는 그대로 + retired = schema.without_retired + assert got["types"] == [retired(t) for t in sent["types"]] assert list(got["구조물군"]) == list(sent["구조물군"]) # 순서 그대로 rows = admin.get(f"{URL}/history").json() assert [r["판"] for r in rows] == [doc["판"]] and len(rows[0]["저장일"]) == 16