Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-09-06 19:02:49 +09:00
7 changed files with 318 additions and 22 deletions
+16 -6
View File
@@ -248,7 +248,16 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
async function persistStructures(next: StructureInstance[]): Promise<void> {
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");
}
}
@@ -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
+13 -15
View File
@@ -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"]
)
+11 -1
View File
@@ -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);
+52
View File
@@ -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<void> {
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<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({
target: () =>
projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null,
@@ -633,6 +683,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
// 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심).
sectionDetail = await loadSectionDetail(projectId, context.route_id);
// 저장하지 않고 나갔던 구조물 초안이 있으면 그것으로 벽을 얹는다(서버 상세는 저장분 기준).
await applyDraftWalls(sectionDetail);
// 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정
const summaryData = existing.longitudinal.data as {
options?: {
@@ -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));
+115
View File
@@ -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;
}