diff --git a/A00_Common/design/CLAUDE.md b/A00_Common/design/CLAUDE.md index 23ec806ac..cfe66b8b1 100644 --- a/A00_Common/design/CLAUDE.md +++ b/A00_Common/design/CLAUDE.md @@ -19,7 +19,7 @@ B05 · B06 한 페이지 뼈대 — 주소 둘(`#/b05-profile` · `#/b06-section 서버 Node 묶음: -- 입구 `logic/design_calc_node.ts`(`build:design-calc` · 계산 등록부 `logic/design_calc_registry.ts` 의 `node:` 줄 · 시험 `test_472_3_calc_registry.py`) · 끌려감 = `design_pick_match` · `design_pipe_ids` · `design_selection`(corridor · server-calc · b07-extent) · `design_store` · `design_structure_{box,ford,ford_pavement,guard,pipe,road,spec}`(b07-extent · b07-note) · 상위 `A00_Common/b_page_state` +- 입구 `logic/design_calc_node.ts`(`build:design-calc` · 계산 등록부 `logic/design_calc_registry.ts` 의 `node:` 줄 · 시험 `test_472_3_calc_registry.py`) · 끌려감 = `design_pick_match` · `design_pipe_ids` · `design_selection`(b07-extent) · `design_store` · `design_structure_{box,ford,ford_pavement,guard,pipe,road,spec}`(b07-extent · b07-note) · 상위 `A00_Common/b_page_state` - 끌려가는 파일 모듈 맨 위에 DOM · window · sessionStorage 금지 · 고치면 해당 build 다시 지어 시험 남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도): diff --git a/A00_Common/design/design_pick_match.ts b/A00_Common/design/design_pick_match.ts index 09bd321bf..6b2d3a8a2 100644 --- a/A00_Common/design/design_pick_match.ts +++ b/A00_Common/design/design_pick_match.ts @@ -6,7 +6,7 @@ * ① 고름 · 그림 둘 다 구조물 id 가 있으면 id 가 같아야(관 = 세션 id `design_pipe_ids` · 옮겨도 그대로) * ② 아니면 같은 자리(`PIPE_MATCH_M` — 같은 자리 거리는 이 한 값) * ③ 고른 부분(부재키)이 있으면 그 부분만 · 부분을 안 골랐으면 그 구조물 그림 모두 - * DOM 은 `data-structure-id` · `data-part`(`tagShape`) — 부분 그림(관 · 벽 · 날개벽 · 물넘이 콘크리트)은 + * DOM 은 `data-structure-id` · `data-part`(`common_util_pick_tag.tagShape`) — 부분 그림(관 · 벽 · 날개벽 · 물넘이 콘크리트)은 * 조정창 손잡이가 같은 고름으로 칠함(`paintPicked` 는 부분 없는 형상만 · 307 「측점 길은 부분 안 켬」). * ========================================================================== */ @@ -32,12 +32,6 @@ export function pickLights(selection: PickSelection, tag: PickTag): boolean { return !selection.partKey || tag.part === selection.partKey; } -/** 그린 형상에 신원표를 닮(부분 없으면 통째 형상). */ -export function tagShape(el: Element, structureId: string | null, part?: string | null): void { - if (structureId) el.setAttribute("data-structure-id", structureId); - if (part) el.setAttribute("data-part", part); -} - /** 카드 안 통째 형상 칠하기 — 고른 구조물 id 와 같은 형상만. 부분 그림(`data-part`) · 관 계열(`pipe-`) · * 노면공 포장층은 제 손잡이(조정창 · 포장층 고름)가 칠함 — 측점 길(알약 · 목록 · 측점선)은 부분을 안 켬(307). */ export function paintPicked(root: ParentNode, selection: PickSelection): void { diff --git a/A00_Common/design/design_pipe_ids.ts b/A00_Common/design/design_pipe_ids.ts index 02c93b7b6..1c8a2cf10 100644 --- a/A00_Common/design/design_pipe_ids.ts +++ b/A00_Common/design/design_pipe_ids.ts @@ -10,6 +10,7 @@ * 프로젝트가 없으면(시험 · 도움 파일) 옛 자리 이름. * ========================================================================== */ +import { setPipeTagSource } from "../../common_util/common_util_pick_tag"; import { readState, writeState } from "../b_page_state"; import { isNear, nearestWithin, readSelection, selectDesign } from "./design_selection"; @@ -26,6 +27,8 @@ let active: string | null = null; /** 지금 프로젝트 — 종단 · 횡단 보기가 열 때. */ export function usePipeIds(projectId: string | null): void { active = projectId; + // 그림 신원표(common_util_pick_tag)가 이 장부를 씀 — 프로젝트 없으면 옛 자리 이름(pipeIdAt 와 같음) + setPipeTagSource(projectId ? (chainageM) => pipeIdAt(chainageM) : null); } const bookOf = (projectId: string): Book => diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index ac9480145..4937ff064 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -14,7 +14,6 @@ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans"; import { readState, writeState } from "../b_page_state"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; -import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import type { StructureInstance, StructureType, @@ -24,7 +23,6 @@ import { readAlignment, toDesignProfile, } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; -import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common"; @@ -86,11 +84,7 @@ import { createProfileOverlays } from "./design_profile_overlays"; import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport"; import { CROSS_PREVIEW_DEBOUNCE_MS, type DesignProfileContext } from "./design_profile_context"; import { pageRouteId } from "./design_page_route"; -import "../../B06_Section/B06_Section_UI_Style.css"; -// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 -// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. -import "../../B06_Section/B06_Section_UI_Style_Cross.css"; -import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; +import { createProfileFrame } from "./design_profile_frame"; // 판 뼈대 · 종단 CSS(700줄 한계) export type { DesignOverlayBoard, @@ -104,30 +98,8 @@ export type { DesignProfileContext } from "./design_profile_context"; export function createDesignProfile(ctx: DesignProfileContext) { const { projectId, onSelectStation, onResetAll, callbacks } = ctx; adoptLegacyProfileKeys(); - const root = document.createElement("section"); - root.className = "b05-route-profile"; - root.dataset.view = ctx.view; - // 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시). - const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단"); - const balanceBar = document.createElement("div"); - balanceBar.className = "b05-route-profile__balance"; - const body = document.createElement("div"); - body.className = "b05-route-profile__body"; - const empty = document.createElement("p"); - empty.className = "b05-route-profile__empty"; - empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다."; - body.append(empty); - // 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성. - // 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시). - // 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다. - // body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다. - const progress = createProgressCircle({ overlay: true }); - progress.root.hidden = true; - const bodyWrap = document.createElement("div"); - bodyWrap.className = "b05-route-profile__body-wrap"; - // 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도 - // 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고). - bodyWrap.append(balanceBar, body, progress.root); + const { root, panelHandle, balanceBar, body, empty, progress, bodyWrap, content, trailing } = + createProfileFrame(ctx.view); // 유토곡선 · 테이블 — 종단 칸 바닥 겹침 판(2026-08-04 · 392-2) · 위 경계 리사이저로 높이 조절. // 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고 // (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은 @@ -144,8 +116,6 @@ export function createDesignProfile(ctx: DesignProfileContext) { body, onChange: subPanelChanged, }); - const content = document.createElement("div"); - content.className = "b05-route-profile__content"; /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ let minCoverTargets: MinCoverPoint[] = []; /** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제). @@ -270,8 +240,6 @@ export function createDesignProfile(ctx: DesignProfileContext) { let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; /** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */ const elevationHold = createElevationHold(body, () => draw()); - const trailing = document.createElement("div"); - trailing.className = "b05-route-profile__trailing"; trailing.append(profileZoom.bar); /** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */ const withToolLead = (wrap: HTMLElement): HTMLElement => { diff --git a/A00_Common/design/design_profile_frame.ts b/A00_Common/design/design_profile_frame.ts new file mode 100644 index 000000000..f014d74a0 --- /dev/null +++ b/A00_Common/design/design_profile_frame.ts @@ -0,0 +1,45 @@ +/* ============================================================================= + * A00_Common/design/design_profile_frame.ts + * 종단도 한 벌의 **판 뼈대**(DOM 틀) — `design_profile.ts` 에서 갈라냄(700줄 한계 · 472-5). + * 그리기 · 편집 · 상태는 저쪽 몫 · 여기는 칸만 만들어 돌려줌. + * ========================================================================== */ + +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createProgressCircle } from "@ui/ui_template_progress"; +import "../../B06_Section/B06_Section_UI_Style.css"; +// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 +// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. +import "../../B06_Section/B06_Section_UI_Style_Cross.css"; +import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; + +export function createProfileFrame(view: string) { + const root = document.createElement("section"); + root.className = "b05-route-profile"; + root.dataset.view = view; + // 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시). + const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단"); + const balanceBar = document.createElement("div"); + balanceBar.className = "b05-route-profile__balance"; + const body = document.createElement("div"); + body.className = "b05-route-profile__body"; + const empty = document.createElement("p"); + empty.className = "b05-route-profile__empty"; + empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다."; + body.append(empty); + // 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성. + // 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시). + // 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다. + // body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + const bodyWrap = document.createElement("div"); + bodyWrap.className = "b05-route-profile__body-wrap"; + // 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도 + // 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고). + bodyWrap.append(balanceBar, body, progress.root); + const content = document.createElement("div"); + content.className = "b05-route-profile__content"; + const trailing = document.createElement("div"); + trailing.className = "b05-route-profile__trailing"; + return { root, panelHandle, balanceBar, body, empty, progress, bodyWrap, content, trailing }; +} diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 8de495459..71b2c1131 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -15,6 +15,7 @@ import aiomysql import numpy as np from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( AlignmentPolicy, build_curves, @@ -23,6 +24,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( ) from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Sections import ( + _annotate_pavement_suggestions, _load_pipe_points, _load_route_polyline, find_cross_file, @@ -421,6 +423,47 @@ def _follow_moved_stations( return {"id": profile["id"], **profile["summary"]} +#: 구조물 측점을 새로 떠 갈음할 때 같은 자리 옛 줄에서 들고 가는 칸(472 덧) — 상단측 출처 +#: (`user` = 손 지정 · 값도 옛 줄) · 포장 제안 표기(`_annotate_pavement_suggestions` · 새 줄 없음). +STATION_CARRY_KEYS = ( + "uphill_side_source", + "pavement_suggested", + "pavement_grade_pct", + "pavement_grade_limit_pct", +) + + +def _carry_station_fields( + old: dict[float, dict[str, Any]], fresh: dict[float, dict[str, Any]] +) -> list[dict[str, Any]]: + """같은 자리 옛 줄의 `STATION_CARRY_KEYS` 를 새 줄에 얹음 · 사용자 상단측은 새 자동값을 이김. + 반환 = 옛 줄 없는 새 자리(포장 표기를 지금 계획선으로 셀 줄).""" + born = [] + for key, station in fresh.items(): + before = old.get(key) + if before is None: + born.append(station) + continue + for name in STATION_CARRY_KEYS: + if name in before and name not in station: + station[name] = before[name] + if before.get("uphill_side_source") == "user" and before.get("uphill_side"): + station["uphill_side"] = before["uphill_side"] + return born + + +def _pavement_for(data: dict[str, Any], stations: list[dict[str, Any]]) -> None: + """새 자리 측점의 포장 제안 표기 — 종단 생성과 같은 셈 · 기준 = 계획선 `criteria`.""" + profiles = data.get("design_profiles") or [] + criteria = (profiles[0].get("criteria") if profiles else None) or {} + names = GradeDesignOptions.__dataclass_fields__.keys() - {"warnings"} + try: + options = GradeDesignOptions(**{k: v for k, v in criteria.items() if k in names}) + except TypeError: + return # 옛 종단 — 기준 칸이 모자람 + _annotate_pavement_suggestions({"design_profiles": profiles, "stations": stations}, options) + + def _merge_irregular_into_longitudinal( project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]] ) -> dict[str, Any] | None: @@ -451,6 +494,9 @@ def _merge_irregular_into_longitudinal( if station.get("kind") in ("irregular", "curve") ] fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations} + born = _carry_station_fields( + {round(float(s.get("chainage_m", 0.0)), 6): s for s in stations}, fresh + ) kept: dict[float, dict[str, Any]] = {} left: list[dict[str, Any]] = [] for station in stations: @@ -470,6 +516,7 @@ def _merge_irregular_into_longitudinal( data["stations"] = sorted(kept.values(), key=lambda s: float(s.get("chainage_m", 0.0))) previous = [round(float(station.get("chainage_m", 0.0)), 6) for station in stations] summary = _follow_moved_stations(data, before, previous) + _pavement_for(data, born) # 계획선을 옮긴 뒤 — 새 자리 경사는 지금 계획선으로 atomic_write_json(path, data) _relabel_cross_files(path.parent.parent / "cross_sections", left) return summary diff --git a/B05_Profile/CLAUDE.md b/B05_Profile/CLAUDE.md index 5d32937d4..406df85e5 100644 --- a/B05_Profile/CLAUDE.md +++ b/B05_Profile/CLAUDE.md @@ -19,7 +19,7 @@ 서버 Node 묶음: -- 입구 `B05_Profile_Corridor_Node.ts`(package.json `build:corridor`) · 끌어감 = `UI_Corridor_*`(Build · Envelope · Frames · Station · Structures) · `UI_IrregularStations` · B06 `UI_Cross_*` 구조물 · A00 `b_page_state` · `design_pick_match` · `design_pipe_ids` · `design_selection` +- 입구 `B05_Profile_Corridor_Node.ts`(package.json `build:corridor`) · 끌어감 = `UI_Corridor_*`(Build · Envelope · Frames · Station · Structures) · B06 `UI_Cross_*` 구조물(화면 · 세션 안 끔 · `test_472_3_calc_registry.py`) - `build:b07-extent` · `build:b07-note` 가 `Api_Structures` · `Api_Pipes_Draft` · `UI_Structures_Balloon` · `UI_Structures_Define` 를 끌어감 - 묶음이 끄는 TS 를 고치면 그 build 다시 지어 시험 · 그 파일 모듈 맨 위에 DOM · window · sessionStorage 금지 diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index 84da01ef3..72132057b 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -9,8 +9,7 @@ * 콘크리트 색은 세월교 바닥판 클래스를 그대로 쓴다. * ========================================================================== */ -import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; -import { tagShape } from "../A00_Common/design/design_pick_match"; +import { pipeTagId, tagShape } from "../common_util/common_util_pick_tag"; import type { BoxLayout, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; @@ -221,7 +220,7 @@ export function appendBoxOverlay( event.stopPropagation(); onSelectSide(role); }); - tagShape(hit, box.chainage_m != null ? pipeIdAt(box.chainage_m) : null, role); // 신원표(323-5) + tagShape(hit, box.chainage_m != null ? pipeTagId(box.chainage_m) : null, role); // 신원표(323-5) hits.set(role, hit); layer.append(hit); } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index ee2e95d3c..5a82725cb 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -10,8 +10,7 @@ * 집수정) → ③ 배관 → ④ 유출구측 성토부선(보호공 삭제 — 2026-08-22) * ========================================================================== */ -import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; -import { tagShape } from "../A00_Common/design/design_pick_match"; +import { pipeTagId, tagShape } from "../common_util/common_util_pick_tag"; import { REVET_EMBED_DEPTH_M, FILL_SLOPE_RATIO_MIN, @@ -91,7 +90,7 @@ export function appendCulvertOverlay( ): RevetHighlightSetter { const { culvert, pipe, pipeCorners } = layout; // 신원표(323-5) — 관 세션 id · 부분 = 조정창 부재키 - const ownerId = culvert.chainage_m != null ? pipeIdAt(culvert.chainage_m) : null; + const ownerId = culvert.chainage_m != null ? pipeTagId(culvert.chainage_m) : null; const diameter = culvert.diameter_m; const roleLabel = (role: "inlet" | "outlet" | "extra") => role === "inlet" ? "유입" : role === "outlet" ? "유출" : "추가(성토부)"; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 0b9bc258c..7e1a8b209 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -22,6 +22,7 @@ import { import { EXTRA_SPAN_DEFAULT } from "./logic/B06_Section_UI_Cross_Culvert_Const"; import type { StructureSpan } from "./logic/B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { LocaleKey } from "@ui/ui_template_locale"; /** * 기슭막이 4축 조작 제어(2026-08-22 확정) — 벽을 눌러 고르고 조정창에서 * 좌우(x)·상하(d, 성토선 대각)·높이(h)·재질(m)을 만진다. 고른 벽은 여기 담아 두어 @@ -113,8 +114,12 @@ export interface ExtraWallControl { syncCount: (chainageM: number, built: number, side?: ExtraSide) => void; } -import { showToast } from "@ui/ui_template_elements"; -import { L } from "./B06_Section_UI_Section_Common"; +/** 한계에 잘린 조작 알림 — 화면(`Cross_View`)이 토스트를 꽂음 · 서버 Node 묶음은 안 꽂음(조작 중인 벽이 + * 없어 부를 일도 없음). 묶음이 화면 코드(토스트 · 글)를 안 끌게 갈라냄(472-2). */ +let notice: (key: LocaleKey) => void = () => {}; +export function setCulvertNotice(fn: (key: LocaleKey) => void): void { + notice = fn; +} /** * 배수관 세트 기하 계산 + 제어 상태 동기화. @@ -455,7 +460,7 @@ export function computeCardCulvert( const applied = activeRevet === "inlet" ? layout.revetShift.inlet : layout.revetShift.outlet; const asked = activeRevet === "inlet" ? requested?.inlet : requested?.outlet; if (asked?.d != null && applied.d != null && Math.abs(asked.d - applied.d) > 0.05) { - showToast(L("B06_Cross_Revet_Limit_Up"), "info"); + notice("B06_Cross_Revet_Limit_Up"); } } // 종단경사 반영은 **3D 전용** 스위치다(2026-08-24 사용자 확인). 횡단도는 언제나 @@ -488,7 +493,7 @@ export function computeCardCulvert( Math.abs(requested.lateralM - applied.lateralM) > 0.05 || Math.abs(requested.slopeM - applied.slopeM) > 0.05; if (moved) { - if (activeRevet === "inlet") showToast(L("B06_Cross_Basin_Limit_Pipe"), "info"); + if (activeRevet === "inlet") notice("B06_Cross_Basin_Limit_Pipe"); inletStructure.updateAdjust(section.chainage_m, applied); } } @@ -518,13 +523,10 @@ export function computeCardCulvert( // 축별로 요청이 잘렸으면 왜 안 됐는지 토스트로 알린다(조작 중인 벽만). if (activeRevet === role) { if (held("x") && Math.abs(requested.x - applied.x) > 0.05) { - showToast( - L( - requested.x < applied.x - ? "B06_Cross_Revet_Limit_Inward" - : "B06_Cross_Revet_Limit_Outward", - ), - "info", + notice( + requested.x < applied.x + ? "B06_Cross_Revet_Limit_Inward" + : "B06_Cross_Revet_Limit_Outward", ); } if ( @@ -533,9 +535,8 @@ export function computeCardCulvert( applied.d != null && Math.abs(requested.d - applied.d) > 0.05 ) { - showToast( - L(requested.d < applied.d ? "B06_Cross_Revet_Limit_Up" : "B06_Cross_Revet_Limit_Down"), - "info", + notice( + requested.d < applied.d ? "B06_Cross_Revet_Limit_Up" : "B06_Cross_Revet_Limit_Down", ); } } diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index f01e99057..dda7e6a5f 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -9,8 +9,7 @@ * ④ 계류측 성토부선·절토선. 관 위 성토 채움은 그리지 않는다(2026-08-25 사용자). * ========================================================================== */ -import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; -import { tagShape } from "../A00_Common/design/design_pick_match"; +import { pipeTagId, tagShape } from "../common_util/common_util_pick_tag"; import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom"; import { appendPipeInfoText, @@ -56,7 +55,7 @@ export function appendFordOverlay( onSelectWall?: (key: FordPick) => void, ): FordHighlightSetter { const { ford } = layout; - const ownerId = ford.chainage_m != null ? pipeIdAt(ford.chainage_m) : null; // 신원표(323-5) + const ownerId = ford.chainage_m != null ? pipeTagId(ford.chainage_m) : null; // 신원표(323-5) const toXY = (point: OffsetPoint): [number, number] => [ x(point.offset), toDisplayY(point.elevation), diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 7b1e747c0..bd9d00b8d 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -14,8 +14,7 @@ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface"; -import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; -import { tagShape } from "../A00_Common/design/design_pick_match"; +import { pipeTagId, tagShape } from "../common_util/common_util_pick_tag"; import { appendChuteBand, chuteRun } from "./B06_Section_UI_Cross_Wall_Hatch"; /** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */ @@ -119,7 +118,7 @@ export function appendFordPavementOverlay( .forEach((el) => { el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement"); // 형상 고름 표식(287-5) — 관 지점 알약 id 와 같은 글 - if (typeof spec.chainage_m === "number") tagShape(el, pipeIdAt(spec.chainage_m), "concrete"); // 관 세션 id · 부분(323-5) + if (typeof spec.chainage_m === "number") tagShape(el, pipeTagId(spec.chainage_m), "concrete"); // 관 세션 id · 부분(323-5) }); // 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) · 성토 쪽 = 성토선 연장(315-2) const depth = spec.depth_m ?? 0; diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 7164088df..8ac2c4316 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -16,7 +16,7 @@ * 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다. * ========================================================================== */ -import { tagShape } from "../A00_Common/design/design_pick_match"; +import { tagShape } from "../common_util/common_util_pick_tag"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { FILL_SLOPE_RATIO_MIN, diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 5cdb8e004..edbed244c 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -46,7 +46,8 @@ import { } from "./B06_Section_UI_Cross_View_Frame"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; -import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { computeCardCulvert, setCulvertNotice } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { showToast } from "@ui/ui_template_elements"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { ExtraWallControl, @@ -87,6 +88,9 @@ import { effectiveCardHalfWidth, } from "./B06_Section_UI_Cross_View_Metrics"; +// 관 세트 조작이 한계에 잘린 까닭 = 토스트(조작 중인 벽만 · Wire 가 부름). +setCulvertNotice((key) => showToast(L(key), "info")); + /** * 횡단 카드 요소. 선택 표시와 면적 강조를 **카드를 다시 만들지 않고** 갈아 끼우는 핸들을 단다 * — 카드를 새로 만들면 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화되기 때문이다. diff --git a/common_util/common_util_pick_tag.ts b/common_util/common_util_pick_tag.ts new file mode 100644 index 000000000..8aa85efe7 --- /dev/null +++ b/common_util/common_util_pick_tag.ts @@ -0,0 +1,26 @@ +/* ============================================================================= + * common_util/common_util_pick_tag.ts + * 그린 형상의 신원표(323-5) — 구조물 그림(카드 · 서버 Node 묶음)이 쓰는 몫만. + * + * 관 id 는 세션 장부(`design_pipe_ids`)가 정함 — 화면이 열 때(`usePipeIds`) `setPipeTagSource` 로 꽂음. + * 안 꽂혔으면(서버 Node · 시험) 옛 자리 이름 `pipe-{누가거리}` — 장부가 프로젝트를 모를 때와 같은 값. + * 묶음이 세션(`b_page_state`)을 안 끌게 갈라냄(472-2). + * ========================================================================== */ + +let pipeSource: ((chainageM: number) => string) | null = null; + +/** 관 id 원천 — 화면이 세션 장부를 꽂음(null = 옛 자리 이름). */ +export function setPipeTagSource(source: ((chainageM: number) => string) | null): void { + pipeSource = source; +} + +/** 그 자리 관의 신원표 id. */ +export function pipeTagId(chainageM: number): string { + return pipeSource ? pipeSource(chainageM) : `pipe-${chainageM.toFixed(2)}`; +} + +/** 그린 형상에 신원표를 닮(부분 없으면 통째 형상). */ +export function tagShape(el: Element, structureId: string | null, part?: string | null): void { + if (structureId) el.setAttribute("data-structure-id", structureId); + if (part) el.setAttribute("data-part", part); +} diff --git a/docs/raw/요구_1017/사용자요구_1017.md b/docs/raw/요구_1017/사용자요구_1017.md index e14539f63..8789374a8 100644 --- a/docs/raw/요구_1017/사용자요구_1017.md +++ b/docs/raw/요구_1017/사용자요구_1017.md @@ -258,3 +258,7 @@ 1. b05/b06의 조작과 데이터 흐름 프론트에서 계산하는지 백엔드에서 계산하는지를 브레인 참고문서를 하나 만들어서 코더들이 b05/b06을 수정할때 참고하게하자. 2. 재정리를 당연히 해야함. 단계적으로 계획서를 세워서 진행하고 데이터 저장의 관점을 보면 보안과 사용자 편의성을 위해 충돌이남. 그래서 로직들은 프론트와 백엔드에서 동작하게 하는 로직을 동시에 구성하고 최종적으로 하나의관리코드에서 선택적으로 할수 있게 반영하는건 어때? 물런 코드가 하나면 유지보수에 좋을테니 더 좋고 어때? + +## 요구 274 (10-19 · 사용자 · 472-7 물음 답) + +1. 측구는 변경이 되었으니 사용자가 이전에 선택한 값른 의미가 없어짐. diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index b28eefb62..ee35c93ad 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -23,6 +23,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | 두 보기가 보는 파생값은 페이지 한 벌이 숨은 동안도 셈 · 보기는 그리기만 · 접힘 = 그리기만 건너뜀 | `design_facility_lane.ts:1-12` · `B05_Profile_UI_Profile_Render.ts:211-226` | `test_272_hidden_summary` · `test_271_facility_lane` | | 되돌리기 걸음 = 사용자 조작으로 값이 실제로 바뀐 때만 · 빈 칸(「""」「{}」「null」) = 칸 없음 · 빈 맵 persist = 칸 지움 | `B05_Profile_UI_Profile_History.ts:50 · 235-236` · `B06_Section_UI_Page_Link_Session.ts:97-105 · 153` | `test_272_history_empty_cells` | | 짝 없는 새 측점(구조물 · 추가 측점 · 새 관) = 계획선 꼭짓점 안 세움 · 짝 있는 옮긴 측점 = 꼭짓점 따라감 | `common_util_structure_station.ts:356` · `B05_Profile_Router_Confirm.py:306-400` | `test_427_1_follow_seat` · `test_459_route_seat_mirror` | +| 구조물 측점 다시 뜸([저장] 상단측 차례 · 측점 맞춤) = 같은 자리 옛 줄의 상단측 출처(`user` 면 값도) · 포장 제안 표기 들고 감 · 새 자리 포장 표기 = 지금 계획선 | `B05_Profile_Router_Confirm.py:426-465` | `test_472_station_user_fields` | | [저장] · [확정] = 서버가 입력만으로 정본 다시 셈 · 브라우저 결과(면적 · 표본 · 측점) 받아 적지 않음 | `design_save.ts:58-68` · `B06_Section_Router_Confirm.py:203-215` | `test_b06_save_recompute` | | 자동저장(디바운스 PUT) 없음 · 화면 잠금 = [저장] · [확정] · [초기화] · 추천 [적용]만 | `design_save.ts:249` | — | | 측점 열쇠 = `common_util_station_key` 한 벌 · 노선 바뀜 = 관 · 구조물 좌표(x · y) 따라감 · 누가거리 다시 매김 | `common_util_station_key.ts:32` · `common_util_route_follow.ts:291` | `test_344_3_station_key_mirror` · `test_410_follow_xy` | diff --git a/resources/tester/helper_286_1_ford_pavement.cjs b/resources/tester/helper_286_1_ford_pavement.cjs index 11a4dd122..117cd3199 100644 --- a/resources/tester/helper_286_1_ford_pavement.cjs +++ b/resources/tester/helper_286_1_ford_pavement.cjs @@ -31,14 +31,10 @@ const ford = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pa { "./logic/B06_Section_UI_Cross_Culvert_Const": {} }, ), "./B06_Section_UI_Cross_Design_Surface": { appendPavementOverlay() {} }, - // 관 세션 id · 신원표(323-5) — 프로젝트 없음 = 옛 자리 이름 - "../A00_Common/design/design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, - "../A00_Common/design/design_pick_match": { - tagShape: (el, id, part) => { - if (id) el.setAttribute("data-structure-id", id); - if (part) el.setAttribute("data-part", part); - }, - }, + // 관 신원표(323-5 · 472-2 에서 common_util 로) — 진짜 · 장부 안 꽂음 = 옛 자리 이름 + "../common_util/common_util_pick_tag": loadTs( + path.join(ROOT, "common_util", "common_util_pick_tag.ts"), + ), }); // 286-3 도수로 · 돌붙임 길이 — 유출(좌 · 우가 높음) 성토사면 1:1.5(5 m 내려감 · 사면 길이 √(7.5²+5²) ≈ 9.01) const sets = loadTs(path.join(ROOT, "common_util", "common_util_culvert_sets.ts")); diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index 6541f52a6..d235a5183 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -49,15 +49,11 @@ pytestmark = pytest.mark.skipif( reason="프론트엔드 의존성 미설치", ) -# 화면 · 세션 끌림(목록 1.3) — 472-2 2-B 가 정리(PIPE_DISPLAY_NAME → common_util · Api_Fetch 세션 · -# 구조물 그림이 고른 것 읽기 · 사진첩). +# 화면 · 세션 끌림(목록 1.3) — 472-2 2-B 가 걷음. 남은 줄 = 헬퍼 정규식 헛잡음 +# (Corridor_Structures_Hash 머리 주석 속 「import」부터 잡아 아래 `import type` 을 값으로 셈) — +# 헬퍼를 고치면 빈 목록. SESSION_PULLS = ( - "A00_Common/b_page_state.ts", - "A00_Common/design/design_pick_match.ts", - "A00_Common/design/design_pipe_ids.ts", - "A00_Common/design/design_selection.ts", "B06_Section/B06_Section_Api_Fetch.ts", - "B06_Section/B06_Section_UI_Section_Common.ts", "config/config_frontend.ts", "ui_template/ui_template_elements.ts", "ui_template/ui_template_elements_base.ts", diff --git a/resources/tester/test_472_station_user_fields.py b/resources/tester/test_472_station_user_fields.py new file mode 100644 index 000000000..49f9843b1 --- /dev/null +++ b/resources/tester/test_472_station_user_fields.py @@ -0,0 +1,130 @@ +"""PLAN 472 덧 (요구 272) — 구조물 측점을 다시 떠도 사용자 상단측 · 포장 제안 표기가 남음. + +증상 — [저장] 상단측 차례(`POST /route/confirm` → `_append_irregular_cross_sections`) · 측점 맞춤 +(`ensure_structure_stations`)이 `_merge_irregular_into_longitudinal` 로 구조물 측점 줄을 새 줄로 +통째 갈음 → 같은 자리 옛 줄의 `uphill_side_source: user`(상단측 손 지정 · 값은 자동으로 돌아감) · +`pavement_*`(포장 제안 표기)가 사라짐(검증_sub8 측점 351). +고침 — 같은 자리(누가거리) 옛 줄에서 `STATION_CARRY_KEYS` 를 들고 감 · 사용자 상단측은 새 자동값을 +이김 · 옛 줄 없는 새 자리는 포장 표기를 지금 계획선으로 셈. +X6 — 옮긴 측점이 들고 가는 횡단 입력 TS `ROW_CARRY_KEYS` = 파이썬 `CARRY_INPUT_KEYS`(거울). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_station_user_fields.py -q +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Router_Confirm import ( # noqa: E402 + _merge_irregular_into_longitudinal, +) +from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS # noqa: E402 + +RELATIVE = "B06_Section/longitudinal/longitudinal.json" +CRITERIA = { + "max_grade_pct": 14.0, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 100.0, + "min_curve_length_m": 20.0, + "min_tangent_length_m": 20.0, + "vertical_curve_skip_delta_pct": 5.0, + "design_speed_kph": 20, + "terrain_type": "special", + "paved": False, + "main_direction": "auto", + "balance_segment_length_m": None, + "start_elevation_offset_m": 0.0, + "end_elevation_offset_m": 0.0, + "enforce_pipe_clearance": False, + "resolved_main_direction": "ascending", +} + + +def _row(at: float, kind: str = "regular", **extra) -> dict: + return {"chainage_m": at, "kind": kind, "station_id": f"s{at}", "uphill_side": "left", **extra} + + +def _pave(grade: float) -> dict: + return { + "pavement_suggested": grade > 14.0, + "pavement_grade_pct": grade, + "pavement_grade_limit_pct": 14.0, + } + + +def _write(tmp_path: Path) -> Path: + # 계획선 0 → 100 m 고르게 20 % 오르막(포장 상한 14 % 넘음) + samples = [{"chainage_m": float(s), "elevation_m": 0.2 * s} for s in range(0, 101, 5)] + stations = [ + _row(0.0, "bp", uphill_side_source="user", **_pave(20.0)), + _row(20.0, uphill_side_source="user", **_pave(20.0)), + _row( + 35.0, + "irregular", + structure="관 D1000", + uphill_side="right", + uphill_side_source="user", + **_pave(20.0), + ), # fmt: skip + _row(40.0, uphill_side="right", uphill_side_source="user", **_pave(20.0)), + _row(100.0, "ep", uphill_side_source="user", **_pave(20.0)), + ] + path = tmp_path / RELATIVE + path.parent.mkdir(parents=True) + longitudinal = { + "stations": stations, + "samples": samples, + "design_profiles": [{"criteria": CRITERIA, "samples": samples}], + } + path.write_text(json.dumps(longitudinal), encoding="utf-8") + return path + + +def _fresh(at: float, **extra) -> dict: + """새로 뜬 구조물 측점 — 상단측은 지형 자동값 · 포장 표기 · 출처 칸 없음.""" + return {"chainage_m": at, "kind": "irregular", "station_id": f"n{at}", "uphill_side": "left", + "structure": "관 D1000", **extra} # fmt: skip + + +def _merged(tmp_path: Path, fresh: list[dict]) -> dict[float, dict]: + path = _write(tmp_path) + _merge_irregular_into_longitudinal(tmp_path, RELATIVE, fresh) + rows = json.loads(path.read_text(encoding="utf-8"))["stations"] + return {row["chainage_m"]: row for row in rows} + + +def test_같은_자리_다시_떠도_사용자_상단측_포장_표기_남음(tmp_path) -> None: + rows = _merged(tmp_path, [_fresh(35.0)]) + row = rows[35.0] + assert row["station_id"] == "n35.0" # 기하 · 글은 새 줄 + assert row["uphill_side"] == "right" and row["uphill_side_source"] == "user" + assert row["pavement_suggested"] is True and row["pavement_grade_pct"] == 20.0 + + +def test_격자_자리에_구조물이_얹혀도_사용자_상단측_남음(tmp_path) -> None: + rows = _merged(tmp_path, [_fresh(35.0), _fresh(40.0, grid=True)]) + assert rows[40.0]["uphill_side"] == "right" and rows[40.0]["uphill_side_source"] == "user" + assert rows[40.0]["pavement_grade_limit_pct"] == 14.0 + + +def test_새_자리는_포장_표기를_지금_계획선으로(tmp_path) -> None: + rows = _merged(tmp_path, [_fresh(55.0)]) + assert 35.0 not in rows # 옛 구조물 측점은 걷힘 + row = rows[55.0] + assert row["uphill_side"] == "left" and "uphill_side_source" not in row # 새 자리 = 지형 자동 + assert row["pavement_suggested"] is True + assert row["pavement_grade_pct"] == 20.0 and row["pavement_grade_limit_pct"] == 14.0 + + +def test_X6_옮긴_측점_횡단_입력_TS_파이썬_한_벌() -> None: + source = (ROOT / "B06_Section/logic/B06_Section_Station_Swap.ts").read_text(encoding="utf-8") + body = re.search(r"export const ROW_CARRY_KEYS = \[(.*?)\] as const;", source, re.S) + assert body is not None + assert tuple(re.findall(r'"([a-z_0-9]+)"', body.group(1))) == CARRY_INPUT_KEYS