diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 2c603a687..fe849d939 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -17,9 +17,10 @@ import { leaveForDashboard } from "../b_missing_data_guard"; import { readByKey, stateKey, writeByKey } from "../b_page_state"; import { showToast } from "@ui/ui_template_elements"; import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations"; -import type { - StructureInstance, - StructureType, +import { + onStaleStructureTypes, + type StructureInstance, + type StructureType, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { fetchSectionContext, @@ -567,6 +568,12 @@ export async function mountCrossView( void structuresPanel.load().then(restoreStructurePick); void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314) }); + // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) + host.onDispose( + onStaleStructureTypes(() => { + if (host.visible()) void structuresPanel.load().then(restoreStructurePick); + }), + ); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); window.clearTimeout(wallAdjustTimer); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index be81dae68..d3b44d3d0 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -57,7 +57,10 @@ import { type SectionDetailResponse, } from "../../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; -import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { + migrateLegacyStations, + onStaleStructureTypes, +} from "../../B05_Profile/B05_Profile_Api_Structures"; import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import { loadCorridorIfFresh, @@ -548,6 +551,12 @@ export async function mountProfileView( onReset: () => uphillOverrides.clear(), }); + // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) + host.onDispose( + onStaleStructureTypes(() => { + if (host.visible() && !restoring) void bridge.load(); + }), + ); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 52b2fc400..a31006619 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -192,8 +192,42 @@ async function requestJson(path: string, init: RequestInit = {}): Promise } } -/** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */ +/** 타입 레지스트리 캐시 — 정의 판(`typesVersion`)이 바뀌면(M02 저장) 버리고 다시 받음(322-0). */ let typesCache: Promise | null = null; +let typesVersion: number | null = null; + +/** 정의 판이 캐시 판과 다르면 종류 캐시를 버림 — 버렸으면 true(B05 · B06 보기를 열 때 · 탭이 다시 보일 때). */ +export async function dropStaleStructureTypes(): Promise { + if (!typesCache || typesVersion === null) return false; + const now = await requestJson<{ 판: number }>("/m02/structure-definition/version", { + method: "GET", + }) + .then((payload) => payload.판) + .catch(() => null); + if (now === null || now === typesVersion) return false; + typesCache = null; + typesVersion = null; + return true; +} + +const staleListeners = new Set<() => void>(); + +function checkStaleOnShow(): void { + if (document.visibilityState !== "visible") return; + void dropStaleStructureTypes().then((stale) => { + if (stale) staleListeners.forEach((reload) => reload()); + }); +} + +/** 탭이 다시 보일 때 정의 판이 바뀌었으면 `reload`(종류 · 패널 다시 읽음) — 떼는 함수를 돌려줌. */ +export function onStaleStructureTypes(reload: () => void): () => void { + if (!staleListeners.size) document.addEventListener("visibilitychange", checkStaleOnShow); + staleListeners.add(reload); + return () => { + staleListeners.delete(reload); + if (!staleListeners.size) document.removeEventListener("visibilitychange", checkStaleOnShow); + }; +} /** * 구조물 타입 목록. @@ -212,7 +246,11 @@ export function fetchStructureTypes(includeDisabled = false): Promise("/projects/structure-types", { method: "GET", }) - .then(async (payload) => withDefinition(payload.types, await definition)) + .then(async (payload) => { + const defined = await definition; + typesVersion = defined?.판 ?? null; + return withDefinition(payload.types, defined); + }) .catch((error) => { typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다. throw error; diff --git a/B05_Profile/B05_Profile_Structures_Migration.py b/B05_Profile/B05_Profile_Structures_Migration.py index 27345f602..0d8d33c52 100644 --- a/B05_Profile/B05_Profile_Structures_Migration.py +++ b/B05_Profile/B05_Profile_Structures_Migration.py @@ -21,7 +21,7 @@ from typing import Any, Iterable from B05_Profile.B05_Profile_Structures_Schema import ( StructureInstance, - station_planting_labels, + all_type_names, structure_type_map, ) from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint @@ -55,16 +55,13 @@ _ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE) def _managed_elsewhere_labels() -> set[str]: - """서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 → - "기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels). + """정본이 이미 관리하는 측점 이름 — 구조물로 재이관하면 안 된다(정본 이중화 → "기타" 고스트). - 관 지점 정본이 관리하는 타입 이름도 같이 막는다 — 그 측점은 이미 관 시설에서 - 투영된 것이라 되옮기면 이름을 못 알아보고 "기타"로 굳는다(기슭막이 2026-08-28 이관). + 지금 판 · 지난 판 정의의 **종류 이름 전부**(322-0) — 심는 측점 이름 · 관 지점 시설 이름만 + 보던 옛 규칙은 M02 에서 종류 이름을 바꾸면 옛 이름 측점을 못 알아보고 「기타」로 굳혔다 + (이름 바꿈 · 심는 규칙 바뀜 둘 다). """ - owned = { - item.name for item in structure_type_map().values() if item.managed_by == PIPE_POINTS_OWNER - } - return station_planting_labels() | owned + return all_type_names() def _is_managed_elsewhere(entry: dict[str, Any]) -> bool: diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 64264b26d..ff1460d09 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -162,6 +162,26 @@ def station_planting_labels() -> set[str]: return {item.name for item in load_structure_types() if is_station_planting_type(item)} +#: 지난 판 정의(`판_N.json`) 폴더 — 정본 옆(M02 `M02_SO_Definition.HISTORY` 와 같은 이름) +HISTORY_DIRNAME = "구조물정의_이력" + + +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_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 4b8f39300..e4158b73b 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -17,6 +17,7 @@ import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { fetchStructures, + dropStaleStructureTypes, fetchStructureTypes, saveStructures, readPendingStructures, @@ -346,6 +347,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { // · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군). // · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 // 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다. + await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0) const [allTypes, stored] = await Promise.all([ fetchStructureTypes(true), fetchStructures(deps.projectId), diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 67d055929..ffea678bb 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -25,7 +25,6 @@ import json import logging import math import re -from functools import lru_cache from pathlib import Path from typing import Any @@ -147,12 +146,12 @@ def pipe_points_file(project_root: Path) -> Path: ) -@lru_cache(maxsize=4) def _registry_defaults(type_id: str) -> dict[str, Any]: """레지스트리 타입 옵션의 기본값 묶음. 저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는 - 기본값과 항상 같은 값이 그림에 들어간다. + 기본값과 항상 같은 값이 그림에 들어간다. 캐시 없음(322-0) — 정의 읽기가 이미 파일 시각으로 + 갈려 M02 저장 즉시 새 기본값(옛 lru 는 서버를 다시 켤 때까지 옛 값). """ structure_type = structure_type_map().get(type_id) if structure_type is None: diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 4cd5736e3..9acae7a03 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -20,6 +20,7 @@ import { } from "../A00_Common/design/design_shared"; import { fetchStructures, + dropStaleStructureTypes, fetchStructureTypes, readPendingStructures, pipesToStructureMarks, @@ -369,6 +370,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (!deps.projectId) return; const projectId = deps.projectId; try { + await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0) const [types, stored, pipeResponse] = await Promise.all([ // B06 은 **구조물 전체**를 넣을 수 있어야 한다(2026-09-07 사용자 지시 5). B05 목록에서 // 빠져 있던 B군 종단배수·F군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이 diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Router.py b/M02_MasterTemplete/StructOptions/M02_SO_Router.py index b18945fdb..448e0e4cc 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Router.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Router.py @@ -12,6 +12,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel +from B05_Profile.B05_Profile_Structures_Schema import definition_version from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -84,6 +85,13 @@ def get_structure_definition(session=Depends(verify_session)) -> dict: return _call(definition.read) +@read_router.get("/structure-definition/version") +def get_structure_definition_version(session=Depends(verify_session)) -> dict: + """지금 정의 판 `{판}` — B05 · B06 종류 캐시가 판을 볼 때(322-0 · 한 벌을 다 받지 않음).""" + access.require(session, "system", None) + return {"판": definition_version()} + + @read_router.get("/structure-definition/history") def get_structure_definition_history(session=Depends(verify_session)) -> list[dict]: """지난 판 목록 `[{판, 저장일}]`(새 판부터) — 로그인 누구나(PLAN 48-2).""" diff --git a/resources/tester/golden_322_structures.json b/resources/tester/golden_322_structures.json new file mode 100644 index 000000000..c40adad68 --- /dev/null +++ b/resources/tester/golden_322_structures.json @@ -0,0 +1,2631 @@ +{ + "★ 메모": [ + "★1 노출형 · 개거 횡단도 none(형상 none → 횡단도 none) — 측점 · 카드 안 생김", + "★2 쇄석(자갈) 포설 = 형상 pavement — 화면 초안 얹기에서도 포장층(「사용 중」 거르기 없앰)", + "★3 기슭막이 표 자리꼴 interval(정의 파일 그대로) — 표 줄만 바뀜 · 알약 띠는 관 지점이라 그대로 · 거울 시험이 지킴", + "★4 코드 숨김 10종(돌쌓기 셋 · D군 넷 · 옛 대피 셋) = 목록 hidden — 옛 대피 셋만 고르기 목록에서 빠짐 → 가림", + "★5 관 공식 한 벌(322-1 카드 이름) — 빈칸 기본값 = 정의 기본값 · 표시 이름이 정의 관종도 앎" + ], + "★": [ + { + "part": "samples", + "key": "cross_drain_exposed/main", + "column": "station", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/main", + "column": "cards", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/main", + "column": "station_py", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/main", + "column": "cards_py", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/blank", + "column": "station", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/blank", + "column": "cards", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/blank", + "column": "station_py", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/blank", + "column": "cards_py", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/main", + "column": "station", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/main", + "column": "cards", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/main", + "column": "station_py", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/main", + "column": "cards_py", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/blank", + "column": "station", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/blank", + "column": "cards", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/blank", + "column": "station_py", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/blank", + "column": "cards_py", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "gravel_surfacing/main", + "column": "cards", + "단계": 1, + "다음": { + "100.00": { + "title": null, + "display": null, + "shape": { + "pavement": [ + "gravel_surfacing" + ] + } + } + }, + "까닭": "★2 쇄석 화면에서도 포장층" + }, + { + "part": "samples", + "key": "gravel_surfacing/blank", + "column": "cards", + "단계": 1, + "다음": { + "100.00": { + "title": null, + "display": null, + "shape": { + "pavement": [ + "gravel_surfacing" + ] + } + } + }, + "까닭": "★2 쇄석 화면에서도 포장층" + }, + { + "part": "pick", + "key": "refuge", + "column": "b05", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "pick", + "key": "refuge", + "column": "b06", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "pick", + "key": "work_yard", + "column": "b05", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "pick", + "key": "work_yard", + "column": "b06", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "pick", + "key": "turnaround", + "column": "b05", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "pick", + "key": "turnaround", + "column": "b06", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "samples", + "key": "pipe/blank", + "column": "station_py", + "단계": 1, + "다음": [ + "파형강관 D1000@101.00" + ], + "까닭": "★5 관 공식 한 벌 — 빈칸 = 정의 기본값(파이썬 D800 → D1000)" + }, + { + "part": "samples", + "key": "pipe/blank", + "column": "cards_py", + "단계": 1, + "다음": { + "101.00": { + "title": "파형강관 D1000", + "shape": {} + } + }, + "까닭": "★5 관 공식 한 벌 — 빈칸 = 정의 기본값(파이썬 D800 → D1000)" + }, + { + "part": "samples", + "key": "pipe/hume", + "column": "cards", + "단계": 1, + "다음": { + "101.00": { + "title": "흄관 D800", + "display": "배수관", + "shape": { + "culvert": { + "hidden_pipe": false, + "body": false + } + } + } + }, + "까닭": "★5 관 공식 한 벌 — 표시 이름도 정의 관종을 앎" + } + ], + "samples": { + "pipe/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "파형강관 D1000", + "display": "배수관", + "shape": { + "culvert": { + "hidden_pipe": false, + "body": false + } + } + } + }, + "weep": false, + "model3d": [ + "pipe:pipe", + "revet:outlet" + ], + "band": 0, + "station_py": [ + "파형강관 D1000@101.00" + ], + "cards_py": { + "101.00": { + "title": "파형강관 D1000", + "shape": { + "culvert": { + "hidden_pipe": false, + "body": false + } + } + } + } + }, + "pipe/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "파형강관 D1000", + "display": "배수관", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "파형강관 D800@101.00" + ], + "cards_py": { + "101.00": { + "title": "파형강관 D800", + "shape": {} + } + } + }, + "pipe/hume": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "흄관 D800", + "display": "흄관 D800", + "shape": { + "culvert": { + "hidden_pipe": false, + "body": false + } + } + } + }, + "weep": false, + "model3d": [ + "pipe:pipe", + "revet:outlet" + ], + "band": 0, + "station_py": [ + "흄관 D800@101.00" + ], + "cards_py": { + "101.00": { + "title": "흄관 D800", + "shape": { + "culvert": { + "hidden_pipe": false, + "body": false + } + } + } + } + }, + "box_culvert/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "BOX암거", + "display": "BOX암거", + "shape": { + "box": true + } + } + }, + "weep": false, + "model3d": [ + "basin:body", + "basin:left:apron", + "basin:right:apron", + "revet:left:wing", + "revet:right:wing" + ], + "band": 0, + "station_py": [ + "BOX암거@101.00" + ], + "cards_py": { + "101.00": { + "title": "BOX암거", + "shape": { + "box": true + } + } + } + }, + "box_culvert/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "BOX암거", + "display": "BOX암거", + "shape": { + "box": true + } + } + }, + "weep": false, + "model3d": [ + "basin:body", + "basin:left:apron", + "basin:right:apron", + "revet:left:wing", + "revet:right:wing" + ], + "band": 0, + "station_py": [ + "BOX암거@101.00" + ], + "cards_py": { + "101.00": { + "title": "BOX암거", + "shape": { + "box": true + } + } + } + }, + "ford_pavement/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "물넘이포장", + "display": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "물넘이포장@101.00" + ], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "ford_pavement": true + } + }, + "101.00": { + "title": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + } + }, + "ford_pavement/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "물넘이포장", + "display": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "물넘이포장@101.00" + ], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "ford_pavement": true + } + }, + "101.00": { + "title": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + } + }, + "ford_bridge/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "세월교", + "display": "세월교", + "shape": { + "ford": true + } + } + }, + "weep": false, + "model3d": [ + "basin:body", + "basin:inlet:apron", + "basin:inlet:floor", + "basin:inlet:wall", + "basin:outlet:apron", + "basin:outlet:floor", + "basin:outlet:wall", + "pipe:pipe", + "revet:inlet:wing", + "revet:outlet:wing" + ], + "band": 0, + "station_py": [ + "세월교@101.00" + ], + "cards_py": { + "101.00": { + "title": "세월교", + "shape": { + "ford": true + } + } + } + }, + "ford_bridge/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "세월교", + "display": "세월교", + "shape": { + "ford": true + } + } + }, + "weep": false, + "model3d": [ + "basin:body", + "basin:inlet:apron", + "basin:inlet:floor", + "basin:inlet:wall", + "basin:outlet:apron", + "basin:outlet:floor", + "basin:outlet:wall", + "pipe:pipe", + "revet:inlet:wing", + "revet:outlet:wing" + ], + "band": 0, + "station_py": [ + "세월교@101.00" + ], + "cards_py": { + "101.00": { + "title": "세월교", + "shape": { + "ford": true + } + } + } + }, + "cross_drain_exposed/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "노출형 횡단수로", + "display": "노출형 횡단수로", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "노출형 횡단수로@101.00" + ], + "cards_py": { + "101.00": { + "title": "노출형 횡단수로", + "shape": {} + } + } + }, + "cross_drain_exposed/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "노출형 횡단수로", + "display": "노출형 횡단수로", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "노출형 횡단수로@101.00" + ], + "cards_py": { + "101.00": { + "title": "노출형 횡단수로", + "shape": {} + } + } + }, + "open_ditch/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "개거(겉도랑)", + "display": "개거(겉도랑)", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "개거(겉도랑)@101.00" + ], + "cards_py": { + "101.00": { + "title": "개거(겉도랑)", + "shape": {} + } + } + }, + "open_ditch/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "개거(겉도랑)", + "display": "개거(겉도랑)", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "개거(겉도랑)@101.00" + ], + "cards_py": { + "101.00": { + "title": "개거(겉도랑)", + "shape": {} + } + } + }, + "cross_underdrain/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "맹암거", + "display": "맹암거", + "shape": { + "underdrain": true + } + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "맹암거@101.00" + ], + "cards_py": { + "101.00": { + "title": "맹암거", + "shape": { + "underdrain": true + } + } + } + }, + "cross_underdrain/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "맹암거", + "display": "맹암거", + "shape": { + "underdrain": true + } + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "맹암거@101.00" + ], + "cards_py": { + "101.00": { + "title": "맹암거", + "shape": { + "underdrain": true + } + } + } + }, + "ditch_side/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "ditch_side/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "ditch_ridge/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "ditch_ridge/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "ditch_berm/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "ditch_berm/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "chute/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "chute/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "slope_drain/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "slope_drain/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "underdrain/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "underdrain/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "retaining_wall/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "101.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "113.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + } + }, + "weep": true, + "model3d": [ + "revet:own" + ], + "band": 1, + "station_py": [ + "옹벽@93.00", + "옹벽@101.00", + "옹벽@113.00" + ], + "cards_py": { + "93.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "101.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "113.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + } + } + }, + "retaining_wall/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "101.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "113.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "옹벽@93.00", + "옹벽@101.00", + "옹벽@113.00" + ], + "cards_py": { + "93.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "101.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "113.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + } + } + }, + "masonry_wet/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + } + }, + "weep": false, + "model3d": [ + "revet:own" + ], + "band": 1, + "station_py": [ + "돌쌓기(찰)@93.00", + "돌쌓기(찰)@101.00", + "돌쌓기(찰)@113.00" + ], + "cards_py": { + "93.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + } + } + }, + "masonry_wet/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "돌쌓기(찰)@93.00", + "돌쌓기(찰)@101.00", + "돌쌓기(찰)@113.00" + ], + "cards_py": { + "93.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + } + } + }, + "masonry_dry/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + } + }, + "weep": false, + "model3d": [ + "revet:own" + ], + "band": 1, + "station_py": [ + "돌쌓기(메)@93.00", + "돌쌓기(메)@101.00", + "돌쌓기(메)@113.00" + ], + "cards_py": { + "93.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + } + } + }, + "masonry_dry/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "돌쌓기(메)@93.00", + "돌쌓기(메)@101.00", + "돌쌓기(메)@113.00" + ], + "cards_py": { + "93.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + } + } + }, + "soil_guard/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + } + }, + "weep": false, + "model3d": [ + "revet:own" + ], + "band": 1, + "station_py": [ + "흙막이@93.00", + "흙막이@101.00", + "흙막이@113.00" + ], + "cards_py": { + "93.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + } + } + }, + "soil_guard/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "101.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "113.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "흙막이@93.00", + "흙막이@101.00", + "흙막이@113.00" + ], + "cards_py": { + "93.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "101.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "113.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + } + } + }, + "boulder_masonry/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + } + }, + "weep": false, + "model3d": [ + "revet:own" + ], + "band": 1, + "station_py": [ + "큰돌쌓기@93.00", + "큰돌쌓기@101.00", + "큰돌쌓기@113.00" + ], + "cards_py": { + "93.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + } + } + }, + "boulder_masonry/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "큰돌쌓기@93.00", + "큰돌쌓기@101.00", + "큰돌쌓기@113.00" + ], + "cards_py": { + "93.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + } + } + }, + "berm/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "소단", + "display": "소단", + "shape": {} + }, + "101.00": { + "title": "소단", + "display": "소단", + "shape": {} + }, + "113.00": { + "title": "소단", + "display": "소단", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "소단@93.00", + "소단@101.00", + "소단@113.00" + ], + "cards_py": { + "93.00": { + "title": "소단", + "shape": {} + }, + "101.00": { + "title": "소단", + "shape": {} + }, + "113.00": { + "title": "소단", + "shape": {} + } + } + }, + "berm/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "소단", + "display": "소단", + "shape": {} + }, + "101.00": { + "title": "소단", + "display": "소단", + "shape": {} + }, + "113.00": { + "title": "소단", + "display": "소단", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "소단@93.00", + "소단@101.00", + "소단@113.00" + ], + "cards_py": { + "93.00": { + "title": "소단", + "shape": {} + }, + "101.00": { + "title": "소단", + "shape": {} + }, + "113.00": { + "title": "소단", + "shape": {} + } + } + }, + "erosion_check/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "erosion_check/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "bed_sill/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "bed_sill/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "check_dam_small/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "check_dam_small/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "revetment/main": { + "station": [ + "start@96.00", + "anchor@101.00", + "end@106.00" + ], + "cards": { + "96.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": {} + }, + "101.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": { + "culvert": { + "hidden_pipe": true, + "body": false + } + } + }, + "106.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": {} + } + }, + "weep": false, + "model3d": [ + "revet:inlet", + "revet:outlet" + ], + "band": 0, + "station_py": [ + "기슭막이@96.00", + "기슭막이@101.00", + "기슭막이@106.00" + ], + "cards_py": { + "96.00": { + "title": "기슭막이", + "shape": {} + }, + "101.00": { + "title": "기슭막이", + "shape": { + "culvert": { + "hidden_pipe": true, + "body": false + } + } + }, + "106.00": { + "title": "기슭막이", + "shape": {} + } + } + }, + "revetment/blank": { + "station": [ + "start@96.00", + "anchor@101.00", + "end@106.00" + ], + "cards": { + "96.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": {} + }, + "101.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": { + "culvert": { + "hidden_pipe": true, + "body": false + } + } + }, + "106.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": {} + } + }, + "weep": false, + "model3d": [ + "revet:inlet", + "revet:outlet" + ], + "band": 0, + "station_py": [ + "기슭막이@96.00", + "기슭막이@101.00", + "기슭막이@106.00" + ], + "cards_py": { + "96.00": { + "title": "기슭막이", + "shape": {} + }, + "101.00": { + "title": "기슭막이", + "shape": { + "culvert": { + "hidden_pipe": true, + "body": false + } + } + }, + "106.00": { + "title": "기슭막이", + "shape": {} + } + } + }, + "evac_road/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "evac_road/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "refuge/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "refuge/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "work_yard/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "work_yard/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "turnaround/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "turnaround/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "mirror/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "mirror/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "curb/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "curb/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "hazard_sign/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "hazard_sign/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "guardrail/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "guardrail/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "position_sign/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "position_sign/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "water_intake/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "water_intake/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "barrier_gate/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "barrier_gate/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "completion_sign/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "completion_sign/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "spoil_bank/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "spoil_bank/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "borrow_pit/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "borrow_pit/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "wildlife_path/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "wildlife_path/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "revegetation/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "revegetation/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "pavement_concrete/main": { + "station": [], + "cards": { + "100.00": { + "title": null, + "display": null, + "shape": { + "pavement": [ + "pavement_concrete" + ] + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "pavement": [ + "pavement_concrete" + ] + } + } + } + }, + "pavement_concrete/blank": { + "station": [], + "cards": { + "100.00": { + "title": null, + "display": null, + "shape": { + "pavement": [ + "pavement_concrete" + ] + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "pavement": [ + "pavement_concrete" + ] + } + } + } + }, + "gravel_surfacing/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "pavement": [ + "gravel_surfacing" + ] + } + } + } + }, + "gravel_surfacing/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "pavement": [ + "gravel_surfacing" + ] + } + } + } + }, + "etc/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "etc/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + } + }, + "pick": { + "pipe": { + "b05": "shown", + "b06": "shown" + }, + "box_culvert": { + "b05": "shown", + "b06": "shown" + }, + "ford_pavement": { + "b05": "shown", + "b06": "shown" + }, + "ford_bridge": { + "b05": "shown", + "b06": "shown" + }, + "cross_drain_exposed": { + "b05": "shown", + "b06": "shown" + }, + "open_ditch": { + "b05": "shown", + "b06": "shown" + }, + "cross_underdrain": { + "b05": "shown", + "b06": "shown" + }, + "ditch_side": { + "b05": "hidden", + "b06": "hidden" + }, + "ditch_ridge": { + "b05": "hidden", + "b06": "hidden" + }, + "ditch_berm": { + "b05": "hidden", + "b06": "hidden" + }, + "chute": { + "b05": "hidden", + "b06": "hidden" + }, + "slope_drain": { + "b05": "hidden", + "b06": "hidden" + }, + "underdrain": { + "b05": "hidden", + "b06": "hidden" + }, + "retaining_wall": { + "b05": "shown", + "b06": "shown" + }, + "masonry_wet": { + "b05": "hidden", + "b06": "hidden" + }, + "masonry_dry": { + "b05": "hidden", + "b06": "hidden" + }, + "soil_guard": { + "b05": "shown", + "b06": "shown" + }, + "boulder_masonry": { + "b05": "hidden", + "b06": "hidden" + }, + "berm": { + "b05": "shown", + "b06": "shown" + }, + "erosion_check": { + "b05": "hidden", + "b06": "hidden" + }, + "bed_sill": { + "b05": "hidden", + "b06": "hidden" + }, + "check_dam_small": { + "b05": "hidden", + "b06": "hidden" + }, + "revetment": { + "b05": "hidden", + "b06": "hidden" + }, + "evac_road": { + "b05": "shown", + "b06": "shown" + }, + "refuge": { + "b05": "absent", + "b06": "absent" + }, + "work_yard": { + "b05": "absent", + "b06": "absent" + }, + "turnaround": { + "b05": "absent", + "b06": "absent" + }, + "mirror": { + "b05": "shown", + "b06": "shown" + }, + "curb": { + "b05": "shown", + "b06": "shown" + }, + "hazard_sign": { + "b05": "shown", + "b06": "shown" + }, + "guardrail": { + "b05": "shown", + "b06": "shown" + }, + "position_sign": { + "b05": "shown", + "b06": "shown" + }, + "water_intake": { + "b05": "shown", + "b06": "shown" + }, + "barrier_gate": { + "b05": "shown", + "b06": "shown" + }, + "completion_sign": { + "b05": "shown", + "b06": "shown" + }, + "spoil_bank": { + "b05": "shown", + "b06": "shown" + }, + "borrow_pit": { + "b05": "shown", + "b06": "shown" + }, + "wildlife_path": { + "b05": "hidden", + "b06": "shown" + }, + "revegetation": { + "b05": "hidden", + "b06": "shown" + }, + "pavement_concrete": { + "b05": "shown", + "b06": "shown" + }, + "gravel_surfacing": { + "b05": "hidden", + "b06": "shown" + }, + "etc": { + "b05": "shown", + "b06": "shown" + } + } +} diff --git a/resources/tester/helper_322_0_golden.cjs b/resources/tester/helper_322_0_golden.cjs new file mode 100644 index 000000000..b60244658 --- /dev/null +++ b/resources/tester/helper_322_0_golden.cjs @@ -0,0 +1,337 @@ +/* PLAN 322-0 전수 표 시험 헬퍼 — 42종 × 견본(구조물 · 관 지점 · 빈칸)마다 지금 화면 함수 결과를 표준출력 JSON 한 벌로. + * 측점(`structureStationMarks` · `pipeStationMarks`) · 카드 이름 · 형상 열쇠(`Section_Store` 초안 얹기 → 미리 뜬 횡단 · + * 벽 · 노면 · 맹암거) · 3D 몸체(`buildCorridorStructures`) · 알약 띠(`buildStructureLane`) · 고르기 목록(B05 · B06 `pickGroups`). + * 입력 = argv[2] JSON(서버 꼴 types · 정의 · 견본 · 격자 · 맨 횡단) — 파이썬 짝이 만듦. 짝 = `test_322_0_golden.py`. + * 진짜 TS 를 그 자리에서 트랜스파일해 Node 로 돌림(서버 부르는 곳 · 무거운 화면 모듈만 흉내). + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const clone = (v) => JSON.parse(JSON.stringify(v)); + +/* ── 브라우저 흉내 ─────────────────────────────────────────────────────── */ +class FakeStorage { + constructor() { + this.map = new Map(); + } + get length() { + return this.map.size; + } + key(i) { + return [...this.map.keys()][i] ?? null; + } + getItem(k) { + return this.map.has(k) ? this.map.get(k) : null; + } + setItem(k, v) { + this.map.set(k, String(v)); + } + removeItem(k) { + this.map.delete(k); + } + clear() { + this.map.clear(); + } +} +class FakeElement { + constructor(tag) { + this.tagName = tag; + this.className = ""; + this.style = {}; + this.dataset = {}; + this.children = []; + this.textContent = ""; + this.classList = { toggle: () => {}, add: () => {}, remove: () => {}, contains: () => false }; + } + append(...nodes) { + this.children.push(...nodes); + } + prepend(...nodes) { + this.children.unshift(...nodes); + } + appendChild(node) { + this.children.push(node); + return node; + } + addEventListener() {} + setAttribute() {} +} +globalThis.window = { + sessionStorage: new FakeStorage(), + localStorage: new FakeStorage(), + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + requestAnimationFrame: (fn) => fn(), + setTimeout, + clearTimeout, + location: { origin: "http://localhost", pathname: "/" }, +}; +globalThis.sessionStorage = window.sessionStorage; +globalThis.localStorage = window.localStorage; +globalThis.requestAnimationFrame = (fn) => fn(); +const docListeners = new Map(); +globalThis.document = { + activeElement: null, + hidden: false, + visibilityState: "visible", + addEventListener: (type, fn) => docListeners.set(type, fn), + removeEventListener: (type, fn) => docListeners.get(type) === fn && docListeners.delete(type), + createElement: (tag) => new FakeElement(tag), +}; +/** 서버 흉내 — 정의 판(`server.version`) · 종류 받은 횟수(`server.typeCalls`). */ +const server = { version: input.definition.판, typeCalls: 0 }; +globalThis.fetch = async (url) => { + const at = String(url); + if (at.includes("/structure-types")) server.typeCalls += 1; + const body = at.includes("structure-definition/version") + ? { 판: server.version } + : at.includes("structure-definition") + ? { ...clone(input.definition), 판: server.version } + : at.includes("/route/structures") + ? { status: "success", project_id: PROJECT, revision: 0, structures: [] } + : { status: "success", schema_version: 1, types: clone(input.types) }; + return { ok: true, status: 200, json: async () => body }; +}; + +/* ── TS 싣기 — 흉내 파일만 갈음 ──────────────────────────────────────────── */ +const file = (...parts) => path.resolve(ROOT, ...parts); +const B05 = (name) => file("B05_Profile", `${name}.ts`); +const B06 = (name) => file("B06_Section", `${name}.ts`); +const PROJECT = "P322"; +let pendingPipes = null; +const STUBS = new Map([ + [ + B06("B06_Section_Api_Fetch"), + { + fetchSectionDetail: async () => currentDetail(), + saveSections: async () => ({}), + previewStructureSections: async (_p, chainages) => + chainages.map((at) => ({ ...plainAt(at), kind: "irregular" })), + }, + ], + [B06("B06_Section_Cross_Refresh"), { USER_TOUCHED_KEYS: [] }], + [ + B06("B06_Section_Api_Culvert_Options"), + { kindChangedChainages: () => [], STRUCTURE_ADJUST_KEYS: [] }, + ], + [ + B06("B06_Section_Structure_Layouts"), + { applyStructureAreaRows: () => {}, structureAreaRows: () => [] }, + ], + [ + B05("B05_Profile_Api_Pipes_Draft"), + { readPendingPipes: () => pendingPipes, writePendingPipes: () => {} }, + ], + [ + file("B04_PreProcess", "B04_PreProcess_Api_Fetch.ts"), + { fetchDetailPipePoints: async () => ({ pipe_points: [] }), noteSnapshotFailed: () => {} }, + ], + [file("A00_Common", "touch_bridge.ts"), { pickPx: () => 6 }], + [file("ui_template", "ui_template_toast.ts"), { showToast: () => {} }], + [file("ui_template", "ui_template_elements.ts"), { showToast: () => {} }], +]); +const resolveFile = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; +const cache = new Map(); +function loadTs(target) { + const full = path.resolve(target); + if (STUBS.has(full)) return STUBS.get(full); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + 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 === "three") return require(path.join(ROOT, "config", "node_modules", "three")); + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (spec.startsWith(prefix)) + return loadTs(resolveFile(path.join(ROOT, dir, spec.slice(prefix.length)))); + if (spec.startsWith(".")) return loadTs(resolveFile(path.join(path.dirname(full), spec))); + return require(spec); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +/* ── 견본 횡단 ───────────────────────────────────────────────────────────── */ +const plainAt = (at) => ({ ...clone(input.plain_section), chainage_m: at, kind: "regular" }); +const currentDetail = () => ({ + longitudinal: { stations: input.grid.map((at) => ({ chainage_m: at, kind: "regular" })) }, + cross_sections: input.grid.map(plainAt), +}); +const frameAtOf = (sections) => (atM) => { + const s = sections.reduce((b, x) => + Math.abs(x.chainage_m - atM) < Math.abs(b.chainage_m - atM) ? x : b, + ); + const [lx, ly] = s.frame.left_xy; + const along = atM - s.chainage_m; + return { + cx: s.center_x - ly * along, + cy: s.center_y + lx * along, + leftX: lx, + leftY: ly, + designZ: s.design?.design_elevation_m ?? null, + }; +}; + +/** 횡단 하나에 얹힌 구조물 그림 자료 — 열쇠 + 고르는 데 쓰는 값만(파이썬 짝 `_shape_of` 와 같은 꼴). */ +const SHAPE_KEYS = [ + "culvert", + "ford", + "box", + "ford_pavement", + "revetment", + "pavement", + "underdrain", +]; +function shapeOf(section) { + const out = {}; + for (const key of SHAPE_KEYS) { + const spec = section[key]; + if (!spec) continue; + if (key === "culvert") out[key] = { hidden_pipe: !!spec.hidden_pipe, body: !!spec.body }; + else if (key === "revetment") + out[key] = { type_id: spec.type_id ?? null, form: spec.form ?? null }; + else if (key === "pavement") out[key] = spec.map((p) => p.type_id).sort(); + else out[key] = true; + } + return out; +} +const at2 = (n) => Number(n).toFixed(2); + +const { structureStationMarks, pipeStationMarks } = loadTs( + file("common_util", "common_util_structure_station.ts"), +); +const { + dropStaleStructureTypes, + fetchStructureTypes, + onStaleStructureTypes, + pipesToStructureMarks, + writePendingStructures, +} = loadTs(B05("B05_Profile_Api_Structures")); +const { loadSectionDetail } = loadTs(B06("B06_Section_Section_Store")); +const { forgetDraftStations } = loadTs(B06("B06_Section_Section_Drafts")); +const { buildCorridorStructures } = loadTs(B05("B05_Profile_UI_Corridor_Structures")); +const { computeRevetmentLayout } = loadTs(B06("B06_Section_UI_Cross_Revetment")); +const { buildStructureLane } = loadTs(B05("B05_Profile_UI_Structures_Marks")); +const { pickGroups } = loadTs(B05("B05_Profile_UI_Structures_Panel_Types")); +const { structureDisplayName } = loadTs(B06("B06_Section_UI_Section_Common")); + +async function main() { + const types = await fetchStructureTypes(true); + const byId = new Map(types.map((type) => [type.type_id, type])); + const out = { samples: {}, pick: {} }; + + // 고르기 목록 — 페이지가 넘기는 거르기 그대로(B05 = 켜진 것 · B06 = 전부) + const pagePicks = { b05: (type) => type.enabled, b06: () => true }; + for (const [page, pick] of Object.entries(pagePicks)) { + const seen = {}; + for (const group of pickGroups(types, pick)) + for (const member of group.types) + seen[member.type.type_id] = group.hidden || member.hidden ? "hidden" : "shown"; + for (const type of types) + (out.pick[type.type_id] ??= {})[page] = seen[type.type_id] ?? "absent"; + } + + let route = 0; + for (const sample of input.samples) { + route += 1; + const type = byId.get(sample.type_id); + const row = {}; + // 측점 + const marks = sample.pipe + ? pipeStationMarks(sample.pipe) + : structureStationMarks(sample.structure, type); + row.station = marks.map((m) => `${m.role}@${at2(m.chainage_m)}`); + + // 초안 얹기(Section_Store) → 카드 이름 · 형상 열쇠 + window.sessionStorage.clear(); + forgetDraftStations(PROJECT); + pendingPipes = sample.pipe ? [clone(sample.pipe)] : null; + writePendingStructures(PROJECT, sample.structure ? [clone(sample.structure)] : null); + const detail = await loadSectionDetail(PROJECT, route, { force: true }); + const touched = detail.cross_sections.filter( + (section) => section.structure || Object.keys(shapeOf(section)).length, + ); + row.cards = Object.fromEntries( + touched.map((section) => [ + at2(section.chainage_m), + { + title: section.structure ?? null, + display: section.structure ? structureDisplayName(section.structure) : null, + shape: shapeOf(section), + }, + ]), + ); + // 벽 물빼기(옹벽만 · 형상칸.weep) + row.weep = touched + .filter((section) => section.revetment) + .some((section) => + (computeRevetmentLayout(section, undefined)?.walls ?? []).some((wall) => wall.retaining), + ); + // 3D 몸체 + const solids = touched.length ? buildCorridorStructures(touched, frameAtOf(touched)) : []; + row.model3d = [...new Set(solids.map((s) => `${s.kind}:${s.key ?? ""}`))].sort(); + + // 알약 띠 + const lane = buildStructureLane({ + structures: sample.pipe + ? pipesToStructureMarks([sample.pipe]) + : [{ ...sample.structure, structure_id: "s1" }], + types, + x: (m) => m, + chainageAt: (px) => px, + maxChainageM: 400, + widthPx: 400, + axisWidthPx: 40, + stationIntervalM: 20, + selectedId: null, + onSelect: () => {}, + }); + row.band = lane.children.filter((el) => el.className.startsWith("b05-structure__band")).length; + out.samples[`${sample.type_id}/${sample.case}`] = row; + } + // 판 확인(322-0 ③) — 같은 판 = 캐시 그대로 · 바뀐 판 = 버리고 다시 받음 · 탭이 다시 보이면 알림 + await fetchStructureTypes(true); + server.typeCalls = 0; + const same = await dropStaleStructureTypes(); + await fetchStructureTypes(true); + const sameCalls = server.typeCalls; + let reloads = 0; + const off = onStaleStructureTypes(() => (reloads += 1)); + server.version += 1; + docListeners.get("visibilitychange")?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await fetchStructureTypes(true); + const staleCalls = server.typeCalls - sameCalls; + off(); + out.version = { + same, + sameCalls, + reloads, + staleCalls, + removed: !docListeners.has("visibilitychange"), + }; + process.stdout.write(JSON.stringify(out)); +} + +main().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_322_0_ghost.py b/resources/tester/test_322_0_ghost.py new file mode 100644 index 000000000..68e3217df --- /dev/null +++ b/resources/tester/test_322_0_ghost.py @@ -0,0 +1,73 @@ +"""PLAN 322-0 ② — 「기타」 유령 길 막기: 종류 이름을 바꾼 뒤에도 옛 이름 측점을 구조물로 되옮기지 않음. + +정의 사본(임시 폴더 · 마스터 안 고침)에서 종류 이름을 바꾸고 앞 판을 `구조물정의_이력/판_N.json` 으로 둠 → +옛 이름 · 새 이름 측점 모두 이관 0건. 옛 꼴 자유 글(기성막이 · 대피로 폭 · 임의 이름)은 종전대로 옮김. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from B05_Profile import B05_Profile_Structures_Schema as schema +from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stations + +MASTER = Path(schema.DEFINITION_PATH) +RENAMED = { + "cross_drain_exposed": "노출수로", + "box_culvert": "박스암거", + "retaining_wall": "옹벽(신)", +} + + +@pytest.fixture() +def renamed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + old = json.loads(MASTER.read_text(encoding="utf-8")) + history = tmp_path / schema.HISTORY_DIRNAME + history.mkdir() + shutil.copy(MASTER, history / f"판_{old['판']}.json") + new = {**old, "판": old["판"] + 1} + new["types"] = [{**t, "name": RENAMED.get(t["type_id"], t["name"])} for t in old["types"]] + path = tmp_path / "구조물정의.json" + path.write_text(json.dumps(new, ensure_ascii=False), encoding="utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(path)) + return old + + +def _moved(label: str) -> int: + plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": label}]) + return len(plan.structures) + len(plan.pipe_facilities) + + +def test_old_and_new_names_are_not_migrated(renamed: dict) -> None: + 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 + + +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_legacy_free_text_still_migrates(renamed: dict) -> None: + assert _moved("기성막이") == 1 # 옛 오기 → 기슭막이(관 지점) + plan = migrate_irregular_stations([{"chainage_m": 300.0, "structure": "대피로 2.0m"}]) + 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() diff --git a/resources/tester/test_322_0_golden.py b/resources/tester/test_322_0_golden.py new file mode 100644 index 000000000..d9ee6099e --- /dev/null +++ b/resources/tester/test_322_0_golden.py @@ -0,0 +1,250 @@ +"""PLAN 322-0 전수 표 시험 — 구조물 42종 × 견본마다 일곱 갈래 결과를 기록(`golden_322_structures.json`)과 맞춤. + +갈래 = 심는 측점(TS `structureStationMarks` · `pipeStationMarks` · 파이썬 `resolve_extra_stations`) · 카드 이름 +(파이썬 측점 이름 · TS 미리 뜬 횡단 이름 · 표시 이름) · 형상 얹기 열쇠(파이썬 attach 차례 · TS `Section_Store` 초안 얹기) +· 3D 몸체 · 알약 띠 · 고르기 목록(B05 · B06 `pickGroups`) · 옹벽 물빼기. +견본 = 종류마다 기본값 한 벌(main) · 빈칸(blank) · 배관은 흄관(hume) 하나 더 — 점형 · 부지형 = 101 m · 구간형 = 93 ~ 113 m +(기준 101) · 관 지점 = 101 m(기슭막이 96 ~ 106 m) · 격자 = 0 ~ 200 m 20 m 마다. + +기록 다시 쓰기 = `GOLDEN_322_RECORD=1` · ★ = 계획(PLAN_구조물표 4장)대로 바뀔 칸 — 기록에 「지금 → 다음」을 적고 +`STAGE` 가 그 단계를 넘으면 다음 값과 맞춤. TS 쪽 = `helper_322_0_golden.cjs`(Node). +""" + +from __future__ import annotations + +import copy +import json +import os +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations +from B05_Profile.B05_Profile_Structures_Repository import structures_file_path +from B05_Profile.B05_Profile_Structures_Schema import load_definition +from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets, pipe_points_file +from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures +from B06_Section.B06_Section_Engine_Tilt import attach_underdrains +from common_util.common_util_drainage_pipes import parse_pipe_points +from common_util.common_util_structure_pavement import attach_pavement_structures +from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent +GOLDEN = HERE / "golden_322_structures.json" +FIXTURE = HERE / "fixtures" / "b06_73_4_body_guard_sections.json" +#: 지금 단계 — ★ 칸은 `단계` 가 이 값 이하면 「다음」 값과 맞춤 +STAGE = 0 +GRID = [float(at) for at in range(0, 201, 20)] +SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain") + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _plain_section() -> dict: + sections = json.loads(FIXTURE.read_text(encoding="utf-8"))["cross_sections"] + plain = copy.deepcopy(next(s for s in sections if s["chainage_m"] == 180)) + for key in (*SHAPE_KEYS, "structure"): + plain.pop(key, None) + return plain + + +def _samples(definition: dict) -> list[dict]: + out: list[dict] = [] + for item in definition["types"]: + defaults = { + option["key"]: option["default"] + for option in item.get("options", []) + if option.get("default") is not None + } + cases = {"main": defaults, "blank": {}} + if item["type_id"] == "pipe": + cases["hume"] = {**defaults, "pipe_kind": "흄관", "pipe_diameter_mm": "800"} + for case, options in cases.items(): + pipe = structure = None + if item.get("managed_by"): + pipe = {"chainage_m": 101.0, "source": "user", "facility": item["type_id"]} + if item["type_id"] == "revetment": + pipe.update(start_m=96.0, end_m=106.0) + if options: + pipe["options"] = options + else: + structure = { + "structure_id": "s1", + "type_id": item["type_id"], + "placement": item["placement"], + "chainage_m": 101.0, + "options": options, + "memo": "", + "placement_source": "manual", + "status": "draft", + "revision": 0, + "geometry": None, + } + if item["placement"] == "interval": + structure.update(start_m=93.0, end_m=113.0) + out.append( + {"type_id": item["type_id"], "case": case, "pipe": pipe, "structure": structure} + ) + return out + + +def _shape_of(section: dict) -> dict: + """TS 짝 `shapeOf` 와 같은 꼴.""" + out: dict = {} + for key in SHAPE_KEYS: + spec = section.get(key) + if not spec: + continue + if key == "culvert": + out[key] = { + "hidden_pipe": bool(spec.get("hidden_pipe")), + "body": bool(spec.get("body")), + } + elif key == "revetment": + out[key] = {"type_id": spec.get("type_id"), "form": spec.get("form")} + elif key == "pavement": + out[key] = sorted(p["type_id"] for p in spec) + else: + out[key] = True + return out + + +def _python_row(sample: dict, root: Path, plain: dict) -> dict: + """서버 길 — 정본 파일을 쓰고 측점 만들기 · 횡단 얹기 차례(`_read_section_detail`)를 그대로.""" + if sample["structure"]: + path = Path(structures_file_path(str(root))) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"revision": 1, "structures": [sample["structure"]]}), encoding="utf-8" + ) + pipes = parse_pipe_points([sample["pipe"]]) if sample["pipe"] else [] + if sample["pipe"]: + path = pipe_points_file(root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"points": [sample["pipe"]]}), encoding="utf-8") + extras = resolve_extra_stations(root, pipes) + sections = [{**copy.deepcopy(plain), "chainage_m": at, "kind": "regular"} for at in GRID] + for at, label in extras: + hit = next((s for s in sections if abs(s["chainage_m"] - at) < 0.005), None) + if hit is None: + hit = {**copy.deepcopy(plain), "chainage_m": at, "kind": "irregular"} + sections.append(hit) + hit["structure"] = label + sections.sort(key=lambda s: s["chainage_m"]) + attach_culvert_sets(root, sections) + relabel_pipe_stations(root, sections) + attach_wall_structures(root, sections) + attach_pavement_structures(root, sections) + attach_underdrains(root, sections) + return { + "station_py": [f"{label}@{at:.2f}" for at, label in extras], + "cards_py": { + f"{s['chainage_m']:.2f}": {"title": s.get("structure"), "shape": _shape_of(s)} + for s in sections + if s.get("structure") or _shape_of(s) + }, + } + + +@pytest.fixture(scope="module") +def result() -> dict: + with tempfile.TemporaryDirectory() as folder: + return _run(Path(folder)) + + +def _run(work: Path) -> dict: + definition = load_definition() + samples = _samples(definition) + plain = _plain_section() + payload = { + "types": with_visible().model_dump()["types"], + "definition": definition, + "samples": samples, + "grid": GRID, + "plain_section": plain, + } + (work / "input.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + out = subprocess.run( + ["node", str(HERE / "helper_322_0_golden.cjs"), str(work / "input.json")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + check=True, + ) + ts = json.loads(out.stdout) + rows: dict = {} + for index, sample in enumerate(samples): + key = f"{sample['type_id']}/{sample['case']}" + rows[key] = {**ts["samples"][key], **_python_row(sample, work / f"p{index}", plain)} + return {"samples": rows, "pick": ts["pick"], "version": ts["version"]} + + +def test_type_cache_follows_definition_version(result: dict) -> None: + """322-0 ③ — 화면 종류 캐시: 같은 판이면 그대로 · M02 저장으로 판이 바뀌면 탭이 다시 보일 때 버리고 다시 받음.""" + assert result["version"] == { + "same": False, + "sameCalls": 0, + "reloads": 1, + "staleCalls": 1, + "removed": True, + } + + +def _cells(table: dict) -> dict[tuple[str, str, str], object]: + return { + (part, key, column): value + for part in ("samples", "pick") + for key, row in table[part].items() + for column, value in row.items() + } + + +def test_golden_matches(result: dict) -> None: + if os.environ.get("GOLDEN_322_RECORD") == "1": + old = json.loads(GOLDEN.read_text(encoding="utf-8")) if GOLDEN.exists() else {} + GOLDEN.write_text( + json.dumps( + { + "★ 메모": old.get("★ 메모", []), + "★": old.get("★", []), + "samples": result["samples"], + "pick": result["pick"], + }, + ensure_ascii=False, + indent=1, + ) + + "\n", + encoding="utf-8", + ) + golden = json.loads(GOLDEN.read_text(encoding="utf-8")) + star = {(s["part"], s["key"], s["column"]): s for s in golden["★"]} + want = _cells(golden) + got = _cells(result) + assert set(got) == set(want), "견본 · 칸 목록이 기록과 다름" + wrong = [] + for cell, value in want.items(): + mark = star.get(cell) + expect = mark["다음"] if mark and STAGE >= mark["단계"] else value + if got[cell] != expect: + wrong.append(f"{cell}: 기록 {expect!r} · 지금 {got[cell]!r}") + assert not wrong, "\n".join(wrong[:40]) + + +def test_golden_covers_every_type(result: dict) -> None: + types = {item["type_id"] for item in load_definition()["types"]} + assert len(types) == 42 + assert {key.split("/")[0] for key in result["samples"]} == types + assert set(result["pick"]) == types + + +def test_star_cells_exist() -> None: + golden = json.loads(GOLDEN.read_text(encoding="utf-8")) + cells = _cells(golden) + for mark in golden["★"]: + assert (mark["part"], mark["key"], mark["column"]) in cells, mark diff --git a/resources/tester/test_322_0_version.py b/resources/tester/test_322_0_version.py new file mode 100644 index 000000000..2d87b92e8 --- /dev/null +++ b/resources/tester/test_322_0_version.py @@ -0,0 +1,58 @@ +"""PLAN 322-0 ③ — 판 확인: 서버 관 기본값 · 판 읽기 길이 정의 사본을 바로 따름(서버 다시 켜기 없음). + +- `_registry_defaults` = 정의 파일이 바뀌면 다음 부름부터 새 기본값(옛 lru = 서버 다시 켤 때까지 옛 값). +- `GET /api/m02/structure-definition/version` = 지금 판 `{판}` 만(화면 종류 캐시가 판을 볼 때 · 한 벌 안 받음). +화면 캐시 쪽은 `test_322_0_golden.py::test_type_cache_follows_definition_version`. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B05_Profile import B05_Profile_Structures_Schema as schema +from B06_Section.B06_Section_Engine_Culvert import _registry_defaults +from common_util.common_util_auth import verify_session +from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router + + +@pytest.fixture +def copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """정의 사본 — 마스터 정본은 안 씀.""" + path = tmp_path / "구조물정의.json" + path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(path)) + return path + + +def _rewrite(path: Path, change) -> None: + doc = json.loads(path.read_text("utf-8")) + change(doc) + path.write_text(json.dumps(doc, ensure_ascii=False), "utf-8") + + +def test_registry_defaults_follow_definition_file(copy: Path) -> None: + before = _registry_defaults("pipe")["pipe_diameter_mm"] + + def bump(doc: dict) -> None: + pipe = next(t for t in doc["types"] if t["type_id"] == "pipe") + option = next(o for o in pipe["options"] if o["key"] == "pipe_diameter_mm") + option["default"] = "1200" if before != "1200" else "800" + + _rewrite(copy, bump) + assert _registry_defaults("pipe")["pipe_diameter_mm"] != before + + +def test_version_endpoint_reads_current_version(copy: Path) -> None: + app = FastAPI() + app.include_router(read_router) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "user_id": 2} + client = TestClient(app) + now = json.loads(copy.read_text("utf-8"))["판"] + assert client.get("/api/m02/structure-definition/version").json() == {"판": now} + _rewrite(copy, lambda doc: doc.update(판=now + 3)) + assert client.get("/api/m02/structure-definition/version").json() == {"판": now + 3}