diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 61cde2be..efb7662f 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -248,7 +248,16 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { async function persistStructures(next: StructureInstance[]): Promise { if (deps.isRestoring()) return; try { - const saved = await saveStructures(deps.projectId, structureRevision, next); + // 판번호는 **쓰기 직전에** 서버에서 다시 받는다. 진입 때 받은 값으로 보내면, + // 화면이 떠 있는 동안 정본이 한 번이라도 바뀌었을 때(다른 창 저장·노선 재계산) + // 409 로 거절되고 아래 실패 처리가 초안을 지워 **사용자가 넣은 구조물이 통째로 + // 사라졌다**(2026-09-06 실측: B06에서 구조물을 넣고 [저장]해도 정본에 안 남음). + const current = await fetchStructures(deps.projectId).catch(() => null); + const saved = await saveStructures( + deps.projectId, + current ? current.revision : structureRevision, + next, + ); structureRevision = saved.revision; writePending(null); // 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다. @@ -263,14 +272,15 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ); } } catch (error) { + // 실패해도 **초안은 지우지 않는다** — 지우면 사용자가 넣은 구조물이 사라진다 + // (2026-09-06 정정). 화면 목록도 그대로 두고 다시 [저장]하면 된다. if (error instanceof StructureConflictError) { - await refreshStructuresFromServer(); - showToast("다른 창에서 구조물이 먼저 저장되어 최신 내용으로 되돌렸습니다.", "error"); + showToast( + "다른 창에서 구조물이 먼저 저장돼 이번 저장을 건너뛰었습니다. 다시 [저장]해 주세요.", + "error", + ); return; } - // 저장이 거절되면 화면에만 남은 항목은 식별자가 없어 고치지도 지우지도 못한다. - // 서버 정본으로 되돌려 화면과 정본을 다시 일치시킨다(2026-08-16 크로스체크 지적 1). - await refreshStructuresFromServer(); showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error"); } } diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py new file mode 100644 index 00000000..e4f9c143 --- /dev/null +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -0,0 +1,107 @@ +"""구조물 정본(C군 사면안정 벽)을 횡단 측점에 얹는다. + +왜 필요한가(2026-09-06 사용자 확정) — 좌측 「구조물 배치」로 넣은 옹벽·돌쌓기 같은 벽이 +횡단도에도 서고 **절·성토 면적에도 반영**돼야 한다. 지금까지 이 목록은 측점만 심고 +(`B05_Profile_Engine_Sections.resolve_extra_stations`) 기하가 없어 면적이 그대로였다. + +방법은 **이미 도는 길을 그대로 태우는 것**이다. 독립 기슭막이가 쓰는 `section.revetment` +제원과 같은 꼴로 얹으면 횡단 기하(`B06_Section_UI_Cross_Revetment`)·설계선 트림·폐회로 +면적(`B06_Section_Structure_Layouts`)·3D 가 손대지 않고 따라온다. 옛 D군 기슭막이가 +관 정본으로 이관되며 사라졌던 `attach_revetments`(2026-08-28)를 C군 벽으로 되살린 것이다. + +치수는 여기서 정하지 않는다 — 높이·형태만 넘기고 도형은 화면·3D 가 같은 산식으로 그린다. + +**짝**: `common_util/common_util_structure_walls.ts` — 브라우저는 아직 저장하지 않은 +목록으로 같은 제원을 만들어야 해서 한 벌을 더 둔다. 거울 테스트 +`tmp/tests/test_b06_structure_walls_mirror.py` 가 같은 값이 나오는지 대조한다. +""" + +import logging +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + +logger = logging.getLogger(__name__) + +# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다. +_EDGE_TOLERANCE_M = 0.02 + +# 구조물 종류 → 횡단 기하가 아는 **형태** 이름. 형태가 벽 높이 한계·두께를 정하므로 +# (`B06_Section_UI_Cross_Revetment.revetHeightLimit`) 가장 가까운 것으로 잇는다. +_FORM_BY_TYPE = { + "masonry_wet": "돌쌓기(찰)", + "masonry_dry": "돌쌓기(메)", + "boulder_masonry": "돌쌓기(메)", + "retaining_wall": "콘크리트", + "soil_guard": "통나무·목재틀", +} + + +def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: + """구조물 정본에서 C군 벽(구간형) 목록을 읽는다. 실패하면 빈 목록.""" + try: + types = structure_type_map() + found: list[dict[str, Any]] = [] + for structure in load_structures(str(project_root))[1]: + definition = types.get(structure.type_id) + if definition is None or definition.group != "C" or definition.placement != "interval": + continue + start, end = structure.start_m, structure.end_m + if start is None or end is None: + continue + options = structure.options or {} + found.append( + { + "structure_id": structure.structure_id, + "type_id": structure.type_id, + "name": definition.name, + "start_m": float(min(start, end)), + "end_m": float(max(start, end)), + "anchor_m": float(structure.anchor_m()), + "form": options.get("form") or _FORM_BY_TYPE.get(structure.type_id), + "height_m": options.get("height_m"), + # C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로 + # 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다. + "side": options.get("side"), + "tiers": options.get("tiers"), + "lift_m": options.get("lift_m"), + "shift_m": options.get("shift_m"), + } + ) + return found + except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다 + logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)") + return [] + + +def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: + """구간 안 측점의 횡단 dict에 `revetment` 키를 얹는다. 얹은 개수를 돌려준다. + + 이미 관 정본이 얹은 세트(`culvert`·`revetment`)가 있는 측점은 **건드리지 않는다** — + 관 유입·유출 벽과 구조물 벽이 한 자리에 겹치면 어느 쪽 그림인지 읽히지 않는다. + 한 측점에 여러 개가 겹치면 먼저 시작한 것을 쓴다(겹침 정리는 사용자 몫). + """ + walls = load_wall_structures(project_root) + if not walls: + return 0 + attached = 0 + for section in cross_sections: + chainage = section.get("chainage_m") + if not isinstance(chainage, (int, float)): + continue + if section.get("culvert") or section.get("revetment"): + continue + for spec in walls: + if ( + spec["start_m"] - _EDGE_TOLERANCE_M + <= float(chainage) + <= spec["end_m"] + _EDGE_TOLERANCE_M + ): + section["revetment"] = spec + attached += 1 + break + if attached: + logger.info("B06 구조물 벽 %d개 측점에 얹음 (구조물 %d건)", attached, len(walls)) + return attached diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 81bcf2cc..a35cecb4 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -15,23 +15,23 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_ from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Sections import ( - cross_filename, prune_stale_cross_files, run_section_generation, ) from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args +from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures from B06_Section.B06_Section_Repository import ( count_cross_sections, create_longitudinal_section, delete_sections_for_route, - get_workflow_route_context, get_cross_section_designs, get_latest_section_options, get_longitudinal_section, get_project_standard_cross_section, get_route_generation_source, + get_workflow_route_context, insert_cross_sections, list_recent_company_projects, update_cross_section_design, @@ -41,18 +41,10 @@ from B06_Section.B06_Section_Router_Design import ( ) from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, - compute_default_designs as _compute_default_designs, - stored_standard_cross_section as _stored_standard_cross_section, - pavement_ranges as _pavement_ranges, - paved_at as _paved_at, - ford_surface_drops, +) +from B06_Section.B06_Section_Router_Design import ( ford_drop_at, -) -from B06_Section.B06_Section_Router_Design import ( - default_section_modes as _default_section_modes, -) -from B06_Section.B06_Section_Router_Design import ( - pavement_suggestions as _pavement_suggestions, + ford_surface_drops, ) from B06_Section.B06_Section_Router_Design import ( read_cross_design_inputs as _read_cross_design_inputs, @@ -60,6 +52,9 @@ from B06_Section.B06_Section_Router_Design import ( from B06_Section.B06_Section_Router_Design import ( recompute_designs_for_alignment as _recompute_designs_for_alignment, ) +from B06_Section.B06_Section_Router_Design import ( + stored_standard_cross_section as _stored_standard_cross_section, +) from B06_Section.B06_Section_Schema import ( CompanyStandardListResponse, CompanyStandardProject, @@ -76,7 +71,6 @@ from B06_Section.B06_Section_Schema import ( SectionSummaryResponse, ) from common_util.common_util_auth import verify_session -from common_util.common_util_route_profile import design_elevation_from_longitudinal from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import get_workflow_state @@ -257,6 +251,9 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic # 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다. # 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points). attach_culvert_sets(root, cross_sections) + # 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 — + # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). + attach_wall_structures(root, cross_sections) return {"longitudinal": longitudinal, "cross_sections": cross_sections} @@ -475,8 +472,9 @@ async def regenerate_sections( await connection.rollback() raise result = sections["result"] - # 재생성 응답도 상세 조회와 같은 배수관 세트 정보를 실어야 화면이 어긋나지 않는다. + # 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다. attach_culvert_sets(project_root, result["cross_sections"]) + attach_wall_structures(project_root, result["cross_sections"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 0985c12d..896ca8de 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -156,7 +156,17 @@ export function computeRevetmentLayout( const requestedHeight = Number(adjust?.h ?? spec.height_m); if (!Number.isFinite(requestedHeight) || requestedHeight <= 0) return null; - const side: "left" | "right" = spec.side === "우" ? "right" : "left"; + // 설치 측이 비어 있으면 **성토가 나는 쪽**에 세운다 — 좌측 「구조물 배치」의 C군 벽 + // (옹벽·돌쌓기 등)에는 설치 측 칸이 없기 때문이다(2026-09-06). 양측 절토면 벽이 설 + // 자리가 없으므로 그리지 않는다(면적도 그대로). + const fillSide = (): "left" | "right" | null => { + if (design.section_mode === "left_cut") return "right"; + if (design.section_mode === "right_cut") return "left"; + if (design.section_mode === "both_fill") return "left"; + return null; + }; + const side = spec.side === "우" ? "right" : spec.side === "좌" ? "left" : fillSide(); + if (!side) return null; const outward = side === "left" ? 1 : -1; const edge = design.road_edges?.[side]; const groundAt = groundInterpolator(section.samples); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index c1a96354..900c0e7d 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -57,6 +57,15 @@ import "./B06_Section_UI_Style_Cross.css"; import "./B06_Section_UI_Style_Cross_Controls.css"; import "./B06_Section_UI_Style_Cross_Areas.css"; import { loadSectionDetail } from "./B06_Section_Section_Store"; +import { + attachWallSpecs, + wallSpecsFrom, + type WallStructureInput, +} from "@util/common_util_structure_walls"; +import { + fetchStructureTypes, + readPendingStructures, +} from "../B05_Profile/B05_Profile_Api_Structures"; import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common"; import "@util/common_util_mass_haul.css"; @@ -142,6 +151,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { projectId, // 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). reveal: () => layout.setOptionsOpen(true), + // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시 + // 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정). + onStructuresChanged: () => void refreshDetailForStructures(), detail: () => sectionDetail, // 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산). wallHeight: (chainageM, role) => { @@ -318,6 +330,44 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } + /** + * 아직 저장하지 않은 구조물(C군 벽)을 횡단 제원에 얹는다 — **조작 중 즉시 반영**. + * + * 저장분은 서버가 상세를 내려보낼 때 이미 얹어 준다(`attach_wall_structures`). + * 세션 초안이 있으면 그것이 화면의 정본이므로, 저장분 기준으로 얹힌 벽을 걷어내고 + * 초안 기준으로 다시 얹는다. 산식은 서버와 **같은 짝**(`common_util_structure_walls`). + */ + async function applyDraftWalls(detail: SectionDetailResponse | null): Promise { + if (!detail || !projectId) return; + const pending = readPendingStructures(projectId); + if (!pending) return; + const types = await fetchStructureTypes().catch(() => []); + const names = new Map( + types + .filter((type) => type.group === "C" && type.placement === "interval") + .map((type) => [type.type_id, type.name] as const), + ); + for (const section of detail.cross_sections) { + if (section.revetment) delete (section as { revetment?: unknown }).revetment; + } + attachWallSpecs( + detail.cross_sections as unknown as Array>, + wallSpecsFrom(pending as unknown as WallStructureInput[], names), + ); + } + + /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ + async function refreshDetailForStructures(): Promise { + if (!projectId || currentRouteId === null) return; + try { + await applyDraftWalls(sectionDetail); + renderSectionDetail(); + } catch (error) { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error"); + } + } + const ensureSampledWidth = createSampleWidener({ target: () => projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null, @@ -633,6 +683,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } // 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심). sectionDetail = await loadSectionDetail(projectId, context.route_id); + // 저장하지 않고 나갔던 구조물 초안이 있으면 그것으로 벽을 얹는다(서버 상세는 저장분 기준). + await applyDraftWalls(sectionDetail); // 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정 const summaryData = existing.longitudinal.data as { options?: { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index e234da6b..227dd80c 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -71,6 +71,9 @@ export interface B06StructuresPanelDeps { /** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가 * 관 목록을 다시 받아 처리한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). */ movePipe?: (fromChainageM: number, toChainageM: number) => void; + /** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로** + * 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */ + onStructuresChanged?: () => void; } export interface B06StructuresPanel { @@ -228,6 +231,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc structures = withLocalIds(next); section.setStructures(structures); if (deps.projectId) writePendingStructures(deps.projectId, structures); + deps.onStructuresChanged?.(); }, onSelect: (structure) => { if (structure) deps.focusChainage(structureAnchorM(structure)); diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts new file mode 100644 index 00000000..1047ed46 --- /dev/null +++ b/common_util/common_util_structure_walls.ts @@ -0,0 +1,115 @@ +/* ============================================================================= + * common_util_structure_walls.ts + * 구조물 정본(C군 사면안정 벽)을 횡단 측점 제원으로 바꾸는 자리 — + * 파이썬 `B06_Section_Engine_Structures_Wall.py` 의 **짝**이다(거울 테스트로 대조). + * + * 왜 두 벌인가(CLAUDE.md 5장) — 서버는 상세를 내려보낼 때 얹어야 하고(저장분 기준), + * 브라우저는 사용자가 **아직 저장하지 않은** 목록으로 즉시 얹어야 한다. 값을 만드는 + * 산식이 같아야 하므로 두 파일 머리에 짝임을 적고 거울 테스트를 둔다. + * + * 얹는 것은 제원뿐이다 — 도형·면적은 `B06_Section_UI_Cross_Revetment` 가 그린다. + * ========================================================================== */ + +/** 구조물 정본 한 건(필요한 칸만). `structures.json` 과 같은 이름을 쓴다. */ +export interface WallStructureInput { + structure_id?: string | null; + type_id: string; + placement?: string | null; + chainage_m: number; + start_m?: number | null; + end_m?: number | null; + options?: Record | null; +} + +/** 측점에 얹는 벽 제원 — `section.revetment` 와 같은 꼴. */ +export interface WallSpec { + structure_id: string | null; + type_id: string; + name: string; + start_m: number; + end_m: number; + anchor_m: number; + form: string | null; + height_m: number | null; + side: string | null; + tiers: number | null; + lift_m: number | null; + shift_m: number | null; +} + +/** 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 파이썬 `_EDGE_TOLERANCE_M`. */ +const EDGE_TOLERANCE_M = 0.02; + +/** 구조물 종류 → 횡단 기하가 아는 형태 이름 — 파이썬 `_FORM_BY_TYPE` 와 같은 표. */ +export const FORM_BY_TYPE: Record = { + masonry_wet: "돌쌓기(찰)", + masonry_dry: "돌쌓기(메)", + boulder_masonry: "돌쌓기(메)", + retaining_wall: "콘크리트", + soil_guard: "통나무·목재틀", +}; + +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** + * C군 벽 구조물을 제원 목록으로 바꾼다. 이름표(`names`)는 타입 레지스트리에서 온다. + * 구간(start·end)이 없는 항목은 건너뛴다 — 벽은 구간형이다. + */ +export function wallSpecsFrom( + structures: readonly WallStructureInput[], + names: ReadonlyMap, +): WallSpec[] { + const specs: WallSpec[] = []; + for (const structure of structures) { + const name = names.get(structure.type_id); + if (!name) continue; + const start = num(structure.start_m); + const end = num(structure.end_m); + if (start === null || end === null) continue; + const options = (structure.options ?? {}) as Record; + specs.push({ + structure_id: structure.structure_id ?? null, + type_id: structure.type_id, + name, + start_m: Math.min(start, end), + end_m: Math.max(start, end), + anchor_m: num(structure.chainage_m) ?? Math.min(start, end), + form: (options.form as string) || FORM_BY_TYPE[structure.type_id] || null, + height_m: num(options.height_m), + side: (options.side as string) ?? null, + tiers: num(options.tiers), + lift_m: num(options.lift_m), + shift_m: num(options.shift_m), + }); + } + return specs; +} + +/** + * 구간 안 측점에 제원을 얹는다(파이썬 `attach_wall_structures`). 얹은 개수를 돌려준다. + * 관 세트가 이미 붙은 측점은 건드리지 않는다 — 한 자리에 두 벽이 겹치면 읽히지 않는다. + */ +export function attachWallSpecs( + sections: Array>, + specs: readonly WallSpec[], +): number { + if (!specs.length) return 0; + let attached = 0; + for (const section of sections) { + const chainage = num(section.chainage_m); + if (chainage === null) continue; + if (section.culvert || section.revetment) continue; + for (const spec of specs) { + if ( + spec.start_m - EDGE_TOLERANCE_M <= chainage && + chainage <= spec.end_m + EDGE_TOLERANCE_M + ) { + section.revetment = spec; + attached += 1; + break; + } + } + } + return attached; +}