feat(B06): 구조물 목록의 C군 벽을 횡단 면적에 반영
좌측 「구조물 배치」로 넣은 옹벽·돌쌓기 같은 벽이 지금까지 측점만 심고 기하가 없어 절·성토 면적이 그대로였음(2026-09-06 사용자 확정: 반영해야 함). - 독립 기슭막이가 쓰던 `section.revetment` 제원 자리에 그대로 얹음 — 기하·설계선 트림·폐회로 면적·3D 가 손대지 않고 따라옴. - 서버 `B06_Section_Engine_Structures_Wall.py`(저장분) ↔ 브라우저 `common_util_structure_walls.ts`(미저장 초안) 짝 + 거울 테스트. - 설치 측 칸이 없는 C군은 성토가 나는 쪽으로 자동 배치, 양측 절토면 세우지 않음. - 구조물 목록이 바뀌면 B06 이 초안을 얹어 다시 그림. 검증: 40m 돌쌓기(찰) 추가 시 그 측점 성토 5.81 → 5.02㎡. 테스트 390 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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 import GradeDesignOptions, resolve_grade_options
|
||||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||||
cross_filename,
|
|
||||||
prune_stale_cross_files,
|
prune_stale_cross_files,
|
||||||
run_section_generation,
|
run_section_generation,
|
||||||
)
|
)
|
||||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
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_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_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 (
|
from B06_Section.B06_Section_Repository import (
|
||||||
count_cross_sections,
|
count_cross_sections,
|
||||||
create_longitudinal_section,
|
create_longitudinal_section,
|
||||||
delete_sections_for_route,
|
delete_sections_for_route,
|
||||||
get_workflow_route_context,
|
|
||||||
get_cross_section_designs,
|
get_cross_section_designs,
|
||||||
get_latest_section_options,
|
get_latest_section_options,
|
||||||
get_longitudinal_section,
|
get_longitudinal_section,
|
||||||
get_project_standard_cross_section,
|
get_project_standard_cross_section,
|
||||||
get_route_generation_source,
|
get_route_generation_source,
|
||||||
|
get_workflow_route_context,
|
||||||
insert_cross_sections,
|
insert_cross_sections,
|
||||||
list_recent_company_projects,
|
list_recent_company_projects,
|
||||||
update_cross_section_design,
|
update_cross_section_design,
|
||||||
@@ -41,18 +41,10 @@ from B06_Section.B06_Section_Router_Design import (
|
|||||||
)
|
)
|
||||||
from B06_Section.B06_Section_Router_Design import (
|
from B06_Section.B06_Section_Router_Design import (
|
||||||
attach_default_designs as _attach_default_designs,
|
attach_default_designs as _attach_default_designs,
|
||||||
compute_default_designs as _compute_default_designs,
|
)
|
||||||
stored_standard_cross_section as _stored_standard_cross_section,
|
from B06_Section.B06_Section_Router_Design import (
|
||||||
pavement_ranges as _pavement_ranges,
|
|
||||||
paved_at as _paved_at,
|
|
||||||
ford_surface_drops,
|
|
||||||
ford_drop_at,
|
ford_drop_at,
|
||||||
)
|
ford_surface_drops,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
from B06_Section.B06_Section_Router_Design import (
|
from B06_Section.B06_Section_Router_Design import (
|
||||||
read_cross_design_inputs as _read_cross_design_inputs,
|
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 (
|
from B06_Section.B06_Section_Router_Design import (
|
||||||
recompute_designs_for_alignment as _recompute_designs_for_alignment,
|
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 (
|
from B06_Section.B06_Section_Schema import (
|
||||||
CompanyStandardListResponse,
|
CompanyStandardListResponse,
|
||||||
CompanyStandardProject,
|
CompanyStandardProject,
|
||||||
@@ -76,7 +71,6 @@ from B06_Section.B06_Section_Schema import (
|
|||||||
SectionSummaryResponse,
|
SectionSummaryResponse,
|
||||||
)
|
)
|
||||||
from common_util.common_util_auth import verify_session
|
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_storage import resolve_stored_project_path
|
||||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||||
from common_util.common_util_workflow_state import get_workflow_state
|
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).
|
# 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points).
|
||||||
attach_culvert_sets(root, cross_sections)
|
attach_culvert_sets(root, cross_sections)
|
||||||
|
# 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 —
|
||||||
|
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
|
||||||
|
attach_wall_structures(root, cross_sections)
|
||||||
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
||||||
|
|
||||||
|
|
||||||
@@ -475,8 +472,9 @@ async def regenerate_sections(
|
|||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
raise
|
raise
|
||||||
result = sections["result"]
|
result = sections["result"]
|
||||||
# 재생성 응답도 상세 조회와 같은 배수관 세트 정보를 실어야 화면이 어긋나지 않는다.
|
# 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다.
|
||||||
attach_culvert_sets(project_root, result["cross_sections"])
|
attach_culvert_sets(project_root, result["cross_sections"])
|
||||||
|
attach_wall_structures(project_root, result["cross_sections"])
|
||||||
return SectionDetailResponse(
|
return SectionDetailResponse(
|
||||||
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
|
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -156,7 +156,17 @@ export function computeRevetmentLayout(
|
|||||||
const requestedHeight = Number(adjust?.h ?? spec.height_m);
|
const requestedHeight = Number(adjust?.h ?? spec.height_m);
|
||||||
if (!Number.isFinite(requestedHeight) || requestedHeight <= 0) return null;
|
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 outward = side === "left" ? 1 : -1;
|
||||||
const edge = design.road_edges?.[side];
|
const edge = design.road_edges?.[side];
|
||||||
const groundAt = groundInterpolator(section.samples);
|
const groundAt = groundInterpolator(section.samples);
|
||||||
|
|||||||
@@ -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_Controls.css";
|
||||||
import "./B06_Section_UI_Style_Cross_Areas.css";
|
import "./B06_Section_UI_Style_Cross_Areas.css";
|
||||||
import { loadSectionDetail } from "./B06_Section_Section_Store";
|
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 { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common";
|
||||||
import "@util/common_util_mass_haul.css";
|
import "@util/common_util_mass_haul.css";
|
||||||
|
|
||||||
@@ -142,6 +151,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
|||||||
projectId,
|
projectId,
|
||||||
// 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자).
|
// 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자).
|
||||||
reveal: () => layout.setOptionsOpen(true),
|
reveal: () => layout.setOptionsOpen(true),
|
||||||
|
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시
|
||||||
|
// 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정).
|
||||||
|
onStructuresChanged: () => void refreshDetailForStructures(),
|
||||||
detail: () => sectionDetail,
|
detail: () => sectionDetail,
|
||||||
// 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산).
|
// 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산).
|
||||||
wallHeight: (chainageM, role) => {
|
wallHeight: (chainageM, role) => {
|
||||||
@@ -318,6 +330,44 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아직 저장하지 않은 구조물(C군 벽)을 횡단 제원에 얹는다 — **조작 중 즉시 반영**.
|
||||||
|
*
|
||||||
|
* 저장분은 서버가 상세를 내려보낼 때 이미 얹어 준다(`attach_wall_structures`).
|
||||||
|
* 세션 초안이 있으면 그것이 화면의 정본이므로, 저장분 기준으로 얹힌 벽을 걷어내고
|
||||||
|
* 초안 기준으로 다시 얹는다. 산식은 서버와 **같은 짝**(`common_util_structure_walls`).
|
||||||
|
*/
|
||||||
|
async function applyDraftWalls(detail: SectionDetailResponse | null): Promise<void> {
|
||||||
|
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<Record<string, unknown>>,
|
||||||
|
wallSpecsFrom(pending as unknown as WallStructureInput[], names),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
|
||||||
|
async function refreshDetailForStructures(): Promise<void> {
|
||||||
|
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({
|
const ensureSampledWidth = createSampleWidener({
|
||||||
target: () =>
|
target: () =>
|
||||||
projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null,
|
projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null,
|
||||||
@@ -633,6 +683,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
// 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심).
|
// 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심).
|
||||||
sectionDetail = await loadSectionDetail(projectId, context.route_id);
|
sectionDetail = await loadSectionDetail(projectId, context.route_id);
|
||||||
|
// 저장하지 않고 나갔던 구조물 초안이 있으면 그것으로 벽을 얹는다(서버 상세는 저장분 기준).
|
||||||
|
await applyDraftWalls(sectionDetail);
|
||||||
// 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정
|
// 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정
|
||||||
const summaryData = existing.longitudinal.data as {
|
const summaryData = existing.longitudinal.data as {
|
||||||
options?: {
|
options?: {
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export interface B06StructuresPanelDeps {
|
|||||||
/** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가
|
/** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가
|
||||||
* 관 목록을 다시 받아 처리한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). */
|
* 관 목록을 다시 받아 처리한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). */
|
||||||
movePipe?: (fromChainageM: number, toChainageM: number) => void;
|
movePipe?: (fromChainageM: number, toChainageM: number) => void;
|
||||||
|
/** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로**
|
||||||
|
* 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */
|
||||||
|
onStructuresChanged?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface B06StructuresPanel {
|
export interface B06StructuresPanel {
|
||||||
@@ -228,6 +231,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
|||||||
structures = withLocalIds(next);
|
structures = withLocalIds(next);
|
||||||
section.setStructures(structures);
|
section.setStructures(structures);
|
||||||
if (deps.projectId) writePendingStructures(deps.projectId, structures);
|
if (deps.projectId) writePendingStructures(deps.projectId, structures);
|
||||||
|
deps.onStructuresChanged?.();
|
||||||
},
|
},
|
||||||
onSelect: (structure) => {
|
onSelect: (structure) => {
|
||||||
if (structure) deps.focusChainage(structureAnchorM(structure));
|
if (structure) deps.focusChainage(structureAnchorM(structure));
|
||||||
|
|||||||
@@ -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<string, unknown> | 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<string, string> = {
|
||||||
|
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<string, string>,
|
||||||
|
): 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<string, unknown>;
|
||||||
|
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<Record<string, unknown>>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user