feat(B06): 소단 = 좌측 깊이 칸 숨김 · 횡단도 소단 고름 ▲▼ 만 · 측점 · 쪽마다 깊이 patch · 고름 조각 키 · 좌측 소단 상자 켬(194-1 · 194-2)
- 정의 「상세 내용」 = 폭만 · depth_m 표시 끔(옛 구간 값 = 기본값으로 읽음 · 새 기본 1 m)
- 짝: BermSpec 쪽마다 깊이 · side_berm ↔ sideBerm · 설계 결과 berm_depth_{side}_m 되싣기(파이썬 ↔ TS)
- 저장: patch berm_depth_{side}_m(≥ 0) · 재계산 살림 목록 짝(RD ↔ CR) · 세션 bermdepth
- 서버 [저장] 다시 셈 = 구조물 정본 소단 구간(load_berm_spans) + 측점 깊이 · 구간 지우면 소단 없음
- 고름: 소단 수평 단 = 높이만(▲ 0 에서 멈춤 · ▼ 사면 끝 앞 멈춤 · ◀▶ 막힘) · 사면 = 가로 · 노면 = 종단
- 고름을 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각
- 새 시험 test_194_1_berm_depth + helper · test_178_2 · test_54_26 고침
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
@@ -177,6 +177,8 @@ export const STATE_REGISTRY = {
|
||||
crossdesign: { bucket: "draft", scope: "route" },
|
||||
/** 설계선 쉬프트 = 쪽마다 노견 확장 `{측점: {left, right}}`(139-6) — [저장] 때 patch 로. */
|
||||
shoulderext: { bucket: "draft", scope: "route" },
|
||||
/** 소단 쪽마다 옮긴 깊이 `{측점: {left, right}}`(194-1 · 횡단도 ▲▼) — [저장] 때 patch 로. */
|
||||
bermdepth: { bucket: "draft", scope: "route" },
|
||||
/** B08 자재 집계표 셋 사용자 값 `{units, mix, surcharge}`(고친 칸만) — [저장] 때 프로젝트 설정으로(121). */
|
||||
"b08-materials": { bucket: "draft", scope: "project" },
|
||||
/** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */
|
||||
|
||||
@@ -455,6 +455,9 @@ export interface CrossDesign {
|
||||
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6) — 사용자 값 · 노체 · 측구 · 사면 · 구조물 같이. */
|
||||
shoulder_extend_left_m?: number;
|
||||
shoulder_extend_right_m?: number;
|
||||
/** 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · 194-1) — 사용자 값 · 기하 입력. */
|
||||
berm_depth_left_m?: number;
|
||||
berm_depth_right_m?: number;
|
||||
cross_slope_pct: number;
|
||||
ditch:
|
||||
| { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number }
|
||||
@@ -578,6 +581,9 @@ export interface CrossSectionPatch {
|
||||
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림. */
|
||||
shoulder_extend_left_m?: number;
|
||||
shoulder_extend_right_m?: number;
|
||||
/** 소단 쪽마다 옮긴 깊이(m · 194-1 · 횡단도 ▲▼). */
|
||||
berm_depth_left_m?: number;
|
||||
berm_depth_right_m?: number;
|
||||
/** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */
|
||||
pipe_length_m?: number;
|
||||
/** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */
|
||||
|
||||
@@ -74,15 +74,14 @@ export function crossDesignChoices(
|
||||
export type ShoulderSide = "left" | "right";
|
||||
export type ShoulderExtends = Map<number, Partial<Record<ShoulderSide, number>>>;
|
||||
|
||||
/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */
|
||||
export function readShoulderExtends(
|
||||
projectId: string | null,
|
||||
routeId: number | null,
|
||||
): ShoulderExtends {
|
||||
type SideState = "shoulderext" | "bermdepth";
|
||||
|
||||
/** 누가거리(숫자) → 쪽마다 값(세션에 있는 쪽만). */
|
||||
function readSides(name: SideState, projectId: string | null, routeId: number | null) {
|
||||
const out: ShoulderExtends = new Map();
|
||||
if (!projectId || routeId === null) return out;
|
||||
const raw = readState<Record<string, Partial<Record<ShoulderSide, number>>>>(
|
||||
"shoulderext",
|
||||
name,
|
||||
projectId,
|
||||
routeId,
|
||||
);
|
||||
@@ -93,8 +92,8 @@ export function readShoulderExtends(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 한 측점 한 쪽 노견 확장을 담는다(0 = 되돌림 · 저장 때 정본의 옛 값을 지움). */
|
||||
export function writeShoulderExtend(
|
||||
function writeSide(
|
||||
name: SideState,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
chainageM: number,
|
||||
@@ -102,11 +101,34 @@ export function writeShoulderExtend(
|
||||
valueM: number,
|
||||
): void {
|
||||
const all =
|
||||
readState<Record<string, Partial<Record<ShoulderSide, number>>>>(
|
||||
"shoulderext",
|
||||
projectId,
|
||||
routeId,
|
||||
) ?? {};
|
||||
readState<Record<string, Partial<Record<ShoulderSide, number>>>>(name, projectId, routeId) ??
|
||||
{};
|
||||
all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) };
|
||||
writeState("shoulderext", all, projectId, routeId);
|
||||
writeState(name, all, projectId, routeId);
|
||||
}
|
||||
|
||||
/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */
|
||||
export const readShoulderExtends = (projectId: string | null, routeId: number | null) =>
|
||||
readSides("shoulderext", projectId, routeId);
|
||||
|
||||
/** 한 측점 한 쪽 노견 확장을 담는다(0 = 되돌림 · 저장 때 정본의 옛 값을 지움). */
|
||||
export const writeShoulderExtend = (
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
chainageM: number,
|
||||
side: ShoulderSide,
|
||||
valueM: number,
|
||||
) => writeSide("shoulderext", projectId, routeId, chainageM, side, valueM);
|
||||
|
||||
/* ── 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · ≥ 0 · 194-1 · 횡단도 ▲▼) — 노견 확장과 같은 꼴.
|
||||
* [저장]·[확정] 이 patch 로 싣고(서버가 다시 셈), 브라우저 재계산이 입력으로 씀. */
|
||||
export const readBermDepths = (projectId: string | null, routeId: number | null) =>
|
||||
readSides("bermdepth", projectId, routeId);
|
||||
|
||||
export const writeBermDepth = (
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
chainageM: number,
|
||||
side: ShoulderSide,
|
||||
depthM: number,
|
||||
) => writeSide("bermdepth", projectId, routeId, chainageM, side, depthM);
|
||||
|
||||
@@ -31,7 +31,11 @@ import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetc
|
||||
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSpec } from "@util/common_util_cross_berm";
|
||||
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
|
||||
import {
|
||||
crossDesignChoices,
|
||||
readBermDepths,
|
||||
readShoulderExtends,
|
||||
} from "./B06_Section_Cross_Design_Session";
|
||||
import {
|
||||
effectiveStandardCross,
|
||||
readRockBoundaryDefault,
|
||||
@@ -95,6 +99,9 @@ export const USER_TOUCHED_KEYS = [
|
||||
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
|
||||
"shoulder_extend_left_m",
|
||||
"shoulder_extend_right_m",
|
||||
// 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력이라 재계산에도 넘김
|
||||
"berm_depth_left_m",
|
||||
"berm_depth_right_m",
|
||||
] as const;
|
||||
|
||||
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
||||
@@ -192,17 +199,39 @@ function bermPayload(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
detail: SectionDetailResponse,
|
||||
): Record<string, BermSessionSpec> {
|
||||
): Record<string, BermSessionSpec & Partial<Record<`berm_depth_${Side}_m`, number>>> {
|
||||
const spans = readBermSpans(projectId, routeId);
|
||||
const out: Record<string, BermSessionSpec> = {};
|
||||
const out: Record<string, BermSessionSpec & Partial<Record<`berm_depth_${Side}_m`, number>>> = {};
|
||||
if (!spans.length) return out;
|
||||
const depthAt = bermDepthReader(projectId, routeId);
|
||||
for (const section of detail.cross_sections) {
|
||||
const spec = bermSpecAt(spans, section.chainage_m);
|
||||
if (spec) out[rockKey(section.chainage_m).toFixed(2)] = spec;
|
||||
if (!spec) continue;
|
||||
const design = (section.design ?? {}) as Record<string, unknown>;
|
||||
const sides: Partial<Record<`berm_depth_${Side}_m`, number>> = {};
|
||||
for (const side of SIDES) {
|
||||
const own = depthAt(section.chainage_m, design, side);
|
||||
if (own !== null) sides[`berm_depth_${side}_m`] = own;
|
||||
}
|
||||
out[rockKey(section.chainage_m).toFixed(2)] = { ...spec, ...sides };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Side = "left" | "right";
|
||||
const SIDES: Side[] = ["left", "right"];
|
||||
|
||||
/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */
|
||||
function bermDepthReader(projectId: string, routeId: number) {
|
||||
const session = readBermDepths(projectId, routeId);
|
||||
return (chainageM: number, design: Record<string, unknown>, side: Side): number | null => {
|
||||
const own = session.get(rockKey(chainageM))?.[side];
|
||||
const stored = design[`berm_depth_${side}_m`];
|
||||
const value = own ?? (typeof stored === "number" ? stored : null);
|
||||
return value === null ? null : Math.max(value, 0);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다.
|
||||
*
|
||||
@@ -256,7 +285,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
||||
// 순간 계단이 사라진다(계획서 3-9).
|
||||
const bermSpans = readBermSpans(projectId, input.routeId);
|
||||
const bermAt = (chainageM: number): BermSpec | null => {
|
||||
const bermDepthAt = bermDepthReader(projectId, input.routeId);
|
||||
const bermAt = (chainageM: number, design: Record<string, unknown>): BermSpec | null => {
|
||||
const spec = bermSpecAt(bermSpans, chainageM);
|
||||
return spec
|
||||
? {
|
||||
@@ -264,6 +294,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
intervalM: spec.interval_m,
|
||||
slopeDeg: spec.slope_deg,
|
||||
depthM: spec.depth_m,
|
||||
// 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분
|
||||
depthLeftM: bermDepthAt(chainageM, design, "left"),
|
||||
depthRightM: bermDepthAt(chainageM, design, "right"),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
@@ -325,7 +358,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
|
||||
paved: pavedBySpecs(section),
|
||||
standard,
|
||||
berm: bermAt(section.chainage_m),
|
||||
berm: bermAt(section.chainage_m, design),
|
||||
rockBoundaryOffsetM,
|
||||
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
|
||||
twoStageSlope:
|
||||
@@ -374,6 +407,11 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
);
|
||||
section.design.shoulder_extend_left_m = extendLeft;
|
||||
section.design.shoulder_extend_right_m = extendRight;
|
||||
// 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1)
|
||||
for (const side of SIDES) {
|
||||
const own = next[`berm_depth_${side}_m`];
|
||||
if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own;
|
||||
}
|
||||
updated.push(section.chainage_m);
|
||||
}
|
||||
return updated;
|
||||
|
||||
@@ -541,6 +541,10 @@ def compute_cross_design(
|
||||
"slope_deg": round(float(berm.slope_deg), 4),
|
||||
"depth_m": round(max(float(berm.depth_m), 0.0), 4),
|
||||
}
|
||||
# 쪽마다 옮긴 깊이(194-1) — 옮긴 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력)
|
||||
for side, own in (("left", berm.depth_left_m), ("right", berm.depth_right_m)):
|
||||
if own is not None:
|
||||
result[f"berm_depth_{side}_m"] = round(max(float(own), 0.0), 4)
|
||||
# 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력)
|
||||
for side, extend in (
|
||||
("left", geometry.shoulder_extend_left),
|
||||
|
||||
@@ -21,6 +21,7 @@ from common_util.common_util_cross_berm import (
|
||||
BermSpec,
|
||||
cut_profile_points,
|
||||
fill_profile_points,
|
||||
side_berm,
|
||||
)
|
||||
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
|
||||
|
||||
@@ -236,7 +237,9 @@ class _SectionGeometry:
|
||||
edge_z = (
|
||||
self.road_z(self.left_extent) if side == "left" else self.road_z(-self.right_extent)
|
||||
)
|
||||
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm, top_z=edge_z)
|
||||
# 쪽마다 옮긴 깊이(194-1) — `side_berm`
|
||||
berm = side_berm(self.berm, side)
|
||||
points = fill_profile_points(start_dist, start_z, self.fill_ratio, berm, top_z=edge_z)
|
||||
self._fill_points_cache[side] = points
|
||||
return points
|
||||
|
||||
|
||||
@@ -120,6 +120,70 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, float]]] | None:
|
||||
"""구조물 정본의 소단 구간 `[(시작, 끝, 제원)]` — [저장] 서버 다시 셈의 소단 원천(194-1).
|
||||
|
||||
짝: TS `bermSpansFromStructures`(같은 규칙 — 폭 0 = 없음 · 깊이 빈 값 = 기본 1.0 · 0 미만 = 0).
|
||||
못 읽으면 None(부르는 쪽이 저장분 `design.berm` 으로 물러섬).
|
||||
"""
|
||||
from common_util.common_util_cross_berm import (
|
||||
BERM_DEFAULT_DEPTH_M,
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
BERM_DEFAULT_WIDTH_M,
|
||||
)
|
||||
|
||||
def number(options: dict[str, Any], key: str, fallback: float) -> float:
|
||||
try:
|
||||
value = float(options.get(key))
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return value if math.isfinite(value) and value >= 0 else fallback
|
||||
|
||||
try:
|
||||
spans: list[tuple[float, float, dict[str, float]]] = []
|
||||
for item in load_structures(str(project_root))[1]:
|
||||
if item.type_id != BERM_TYPE_ID:
|
||||
continue
|
||||
anchor = item.chainage_m if item.chainage_m is not None else item.start_m
|
||||
start = item.start_m if item.start_m is not None else anchor
|
||||
end = item.end_m if item.end_m is not None else anchor
|
||||
if start is None or end is None:
|
||||
continue
|
||||
options = item.options or {}
|
||||
width = number(options, "width_m", BERM_DEFAULT_WIDTH_M)
|
||||
if width <= 0:
|
||||
continue
|
||||
raw = options.get("depth_m")
|
||||
try:
|
||||
depth = BERM_DEFAULT_DEPTH_M if raw in (None, "") else max(float(raw), 0.0)
|
||||
except (TypeError, ValueError):
|
||||
depth = BERM_DEFAULT_DEPTH_M
|
||||
if not math.isfinite(depth):
|
||||
depth = BERM_DEFAULT_DEPTH_M
|
||||
spec = {
|
||||
"width_m": width,
|
||||
"interval_m": number(options, "interval_m", BERM_DEFAULT_INTERVAL_M),
|
||||
"slope_deg": number(options, "slope_deg", BERM_DEFAULT_SLOPE_DEG),
|
||||
"depth_m": depth,
|
||||
}
|
||||
spans.append((float(min(start, end)), float(max(start, end)), spec))
|
||||
return spans
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽으면 저장분으로 물러섬
|
||||
logger.exception("B06 소단 구간을 읽지 못했습니다 (저장분 소단을 씀)")
|
||||
return None
|
||||
|
||||
|
||||
def berm_span_at(
|
||||
spans: list[tuple[float, float, dict[str, float]]], chainage_m: float
|
||||
) -> dict[str, float] | None:
|
||||
"""그 측점을 덮는 소단 제원 — 겹치면 먼저 놓은 것(TS `bermSpecAt` 짝)."""
|
||||
for start, end, spec in spans:
|
||||
if start - 1e-6 <= chainage_m <= end + 1e-6:
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""구간 안 측점의 횡단 dict에 `revetment` 키를 얹는다. 얹은 개수를 돌려준다.
|
||||
|
||||
|
||||
@@ -160,6 +160,9 @@ USER_TOUCHED_KEYS = (
|
||||
# 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`)
|
||||
"shoulder_extend_left_m",
|
||||
"shoulder_extend_right_m",
|
||||
# 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력(`stored_berm`)
|
||||
"berm_depth_left_m",
|
||||
"berm_depth_right_m",
|
||||
)
|
||||
|
||||
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
|
||||
@@ -256,14 +259,20 @@ def enforce_design_lines(
|
||||
받아 적었다(CLAUDE.md 5장 「[저장]은 서버가 정본으로 다시 계산」 어긋남 · B07 이 저장된 선을
|
||||
그림). 입력 = 저장분(지반유형 · 단면유형 · 측구 · 포장 · 암 경계 · 암 절토 경사 · 소단 · 노견
|
||||
확장) + 지금 종단 계획고 · 세월교 월류 하강 · 곡선부 확폭. 사용자 조작값은 승계.
|
||||
소단 제원은 구조물 정본(`structures.json`)의 구간이 원천 — 쪽마다 옮긴 깊이는 측점 patch(194-1).
|
||||
"""
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans
|
||||
|
||||
ford_drops = ford_surface_drops(project_root)
|
||||
spans = load_berm_spans(project_root)
|
||||
changed = 0
|
||||
for section in cross_sections:
|
||||
design = section.get("design")
|
||||
if not isinstance(design, dict):
|
||||
continue
|
||||
chainage = float(section.get("chainage_m", 0.0))
|
||||
if spans is not None: # 구간을 지웠으면 소단 없음 · 못 읽으면 저장분 그대로
|
||||
design = {**design, "berm": berm_span_at(spans, chainage)}
|
||||
try:
|
||||
recomputed = compute_cross_design(
|
||||
section.get("samples", []),
|
||||
@@ -449,22 +458,34 @@ def stored_cut_slope(stored: dict[str, Any]) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def stored_berm(stored: dict[str, Any]) -> BermSpec | None:
|
||||
"""저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC)."""
|
||||
spec = stored.get("berm")
|
||||
def berm_from(spec: Any, sides: dict[str, Any]) -> BermSpec | None:
|
||||
"""소단 제원 dict + 쪽마다 옮긴 깊이(`berm_depth_{side}_m` · 194-1) → BermSpec."""
|
||||
if not isinstance(spec, dict):
|
||||
return None
|
||||
|
||||
def own(side: str) -> float | None:
|
||||
value = sides.get(f"berm_depth_{side}_m")
|
||||
ok = isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
return max(float(value), 0.0) if ok else None
|
||||
|
||||
try:
|
||||
return BermSpec(
|
||||
width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)),
|
||||
interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)),
|
||||
slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)),
|
||||
depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0),
|
||||
depth_left_m=own("left"),
|
||||
depth_right_m=own("right"),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def stored_berm(stored: dict[str, Any]) -> BermSpec | None:
|
||||
"""저장분에 남은 소단 제원 + 쪽마다 옮긴 깊이 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC)."""
|
||||
return berm_from(stored.get("berm"), stored)
|
||||
|
||||
|
||||
def recompute_designs_for_alignment(
|
||||
longitudinal: dict[str, Any],
|
||||
cross_sections: list[dict[str, Any]],
|
||||
@@ -500,13 +521,10 @@ def recompute_designs_for_alignment(
|
||||
session_berms: dict[float, BermSpec] = {}
|
||||
for raw_key, spec in (berms or {}).items():
|
||||
try:
|
||||
session_berms[round(float(raw_key), 3)] = BermSpec(
|
||||
width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)),
|
||||
interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)),
|
||||
slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)),
|
||||
depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0),
|
||||
)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
parsed = berm_from(spec, spec if isinstance(spec, dict) else {}) # 쪽 깊이도(194-1)
|
||||
if parsed is not None:
|
||||
session_berms[round(float(raw_key), 3)] = parsed
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for section in cross_sections:
|
||||
chainage = float(section.get("chainage_m", 0.0))
|
||||
|
||||
@@ -184,6 +184,9 @@ class CrossSectionPatch(BaseModel):
|
||||
# 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림(정본의 옛 값을 지움)
|
||||
shoulder_extend_left_m: float | None = Field(default=None, ge=0, le=20)
|
||||
shoulder_extend_right_m: float | None = Field(default=None, ge=0, le=20)
|
||||
# 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · 194-1 · 횡단도 ▲▼) — 0 = 노면 끝 높이(도로 위 금지)
|
||||
berm_depth_left_m: float | None = Field(default=None, ge=0, le=50)
|
||||
berm_depth_right_m: float | None = Field(default=None, ge=0, le=50)
|
||||
inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None
|
||||
basin_adjust: BasinAdjustPatch | None = None
|
||||
# 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface CrossDesignChange {
|
||||
ditch_choice: boolean | null;
|
||||
/** 설계선 쉬프트(139-6) — 있으면 그 쪽 노견 확장만 바꾸는 변경(다른 칸은 안 씀). */
|
||||
shoulder_extend?: { side: "left" | "right"; valueM: number };
|
||||
/** 소단 높이(194-1) — 있으면 그 쪽 소단 깊이(노면 끝 아래 m)만 바꾸는 변경. */
|
||||
berm_depth?: { side: "left" | "right"; valueM: number };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
* 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 ·
|
||||
* 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 ·
|
||||
* 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고).
|
||||
* 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬.
|
||||
* 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design";
|
||||
import { clipSegment, type XY } from "../A00_Common/b_polygon_clip";
|
||||
import { sideRole } from "@util/common_util_cross_design_geometry";
|
||||
import { BERM_DEFAULT_DEPTH_M } from "@util/common_util_cross_berm";
|
||||
import {
|
||||
arrowsBelongToProfile,
|
||||
bindArrowKeys,
|
||||
@@ -61,7 +65,8 @@ export type CrossSide = "left" | "right";
|
||||
type ZoneDesign = Pick<
|
||||
CrossDesign,
|
||||
"road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode"
|
||||
>;
|
||||
> &
|
||||
Partial<Pick<CrossDesign, "berm" | "berm_depth_left_m" | "berm_depth_right_m">>;
|
||||
|
||||
/**
|
||||
* offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 +
|
||||
@@ -104,8 +109,46 @@ export const pieceZone = (design: ZoneDesign, piece: DesignPiece) =>
|
||||
|
||||
/** 고른 선이 움직이는 축(193-1) — 도로측(노면 · 노견) = 높이(y · 종단 계획고 ▲▼) ·
|
||||
* 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */
|
||||
export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): "x" | "y" =>
|
||||
pieceZone(design, piece).zone === "road" ? "y" : "x";
|
||||
export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis =>
|
||||
isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x";
|
||||
/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1). */
|
||||
export type MoveAxis = "x" | "y" | "depth";
|
||||
|
||||
/** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */
|
||||
export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number =>
|
||||
design[`berm_depth_${side}_m`] ?? design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M;
|
||||
|
||||
/** 소단 수평 단인가 — 성토측 · 폭 = 소단 폭 · 안쪽 끝 표고 = 노면 끝 − 깊이(194-1). */
|
||||
export function isBermPiece(design: ZoneDesign, piece: DesignPiece): boolean {
|
||||
const berm = design.berm;
|
||||
if (!berm || piece.structure) return false;
|
||||
const { zone, side } = pieceZone(design, piece);
|
||||
const edge = design.road_edges?.[side];
|
||||
if (zone !== "fill" || !edge) return false;
|
||||
const rise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m;
|
||||
const low = Math.min(piece.from.elevation, piece.to.elevation);
|
||||
const high = Math.max(piece.from.elevation, piece.to.elevation);
|
||||
return (
|
||||
Math.abs(Math.abs(piece.to.offset - piece.from.offset) - berm.width_m) < 0.05 &&
|
||||
Math.abs(low - (edge.elevation_m - bermDepthOf(design, side))) < 0.02 &&
|
||||
high - low <= rise + 0.01
|
||||
);
|
||||
}
|
||||
|
||||
/** 조각 키 = 갈래 + 쪽 + 차례(가운데에서 바깥으로) — 다시 그려도 안 바뀜(194-2). */
|
||||
export function pieceKeys(design: ZoneDesign, pieces: DesignPiece[]): string[] {
|
||||
const mid = (p: DesignPiece) => Math.abs(p.from.offset + p.to.offset) / 2;
|
||||
const kinds = pieces.map((p) => {
|
||||
const { zone, side } = pieceZone(design, p);
|
||||
return `${isBermPiece(design, p) ? "berm" : zone}${p.structure ? "+s" : ""}:${side}`;
|
||||
});
|
||||
return pieces.map((p, i) => {
|
||||
const rank = pieces.filter(
|
||||
(q, j) => kinds[j] === kinds[i] && (mid(q) < mid(p) || (mid(q) === mid(p) && j < i)),
|
||||
).length;
|
||||
return `${kinds[i]}:${rank}`;
|
||||
});
|
||||
}
|
||||
|
||||
const gap = (a: CrossPoint, b: CrossPoint): number =>
|
||||
Math.hypot(a.offset - b.offset, a.elevation - b.elevation);
|
||||
@@ -229,13 +272,22 @@ export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 페이지 한 칸 고름 — 측점 + 조각 번호(카드를 다시 그려도 되살림 · 137-3) ───────── */
|
||||
let picked: { stationId: string; index: number } | null = null;
|
||||
export const pickedLineAt = (stationId: string): number | null =>
|
||||
picked?.stationId === stationId ? picked.index : null;
|
||||
/* ── 페이지 한 칸 고름 — 측점 + 조각 키(카드를 다시 그려도 되살림 · 137-3) ───────────
|
||||
* 번호가 아니라 키(`pieceKeys`)로 기억 — 옮겨 다시 그리면 조각 차례가 바뀌어도 같은 조각(194-2).
|
||||
* 키를 안 적은 측점(카드 밖 시험)은 번호 그대로. */
|
||||
let picked: { stationId: string; key: string } | null = null;
|
||||
const keysByStation = new Map<string, string[]>();
|
||||
export function pickedLineAt(stationId: string): number | null {
|
||||
if (picked?.stationId !== stationId) return null;
|
||||
const keys = keysByStation.get(stationId);
|
||||
const index = keys ? keys.indexOf(picked.key) : Number(picked.key);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
export function setPickedLine(stationId: string, index: number | null): void {
|
||||
if (index !== null) picked = { stationId, index };
|
||||
else if (picked?.stationId === stationId) picked = null;
|
||||
if (index !== null) {
|
||||
const keys = keysByStation.get(stationId);
|
||||
picked = { stationId, key: keys ? (keys[index] ?? "") : String(index) };
|
||||
} else if (picked?.stationId === stationId) picked = null;
|
||||
}
|
||||
|
||||
/* ── 카드 배선 ─────────────────────────────────────────────────────────── */
|
||||
@@ -270,6 +322,54 @@ export function shiftedExtend(now: number, side: CrossSide, screenDir: -1 | 1, k
|
||||
return Math.max(0, Math.round((now + outward) * 10) / 10);
|
||||
}
|
||||
|
||||
/** 소단 높이(194-1) — 쪽마다 깊이(노면 끝 아래 m · ≥ 0) · 고름 켬/끔을 좌측 소단 상자에 알림. */
|
||||
export interface BermDepthControl {
|
||||
set: (side: CrossSide, depthM: number) => void;
|
||||
focus: (on: boolean) => void;
|
||||
}
|
||||
|
||||
/** 소단 고름 → 좌측 패널 소단 상자 켬(194-2) — 페이지가 단다(측점 · null = 끔). */
|
||||
let bermHost: ((chainageM: number | null) => void) | null = null;
|
||||
let bermHostAt: number | null = null;
|
||||
export function setBermPickHost(host: ((chainageM: number | null) => void) | null): void {
|
||||
bermHost = host;
|
||||
bermHostAt = null;
|
||||
}
|
||||
function tellBermHost(chainageM: number | null): void {
|
||||
if (chainageM === bermHostAt) return; // 다시 그린 카드의 되살림은 한 번만
|
||||
bermHostAt = chainageM;
|
||||
bermHost?.(chainageM);
|
||||
}
|
||||
|
||||
/** 카드 → 고름 이동 창구 — 노견 확장(139-6) · 소단 깊이(194-1) 둘 다 카드 버튼과 같은 창구. */
|
||||
export function designLineMoves(
|
||||
section: Pick<CrossSection, "station_id" | "chainage_m" | "design">,
|
||||
onDesignChange?: (chainageM: number, change: CrossDesignChange) => void,
|
||||
): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl } {
|
||||
const design = section.design;
|
||||
if (!onDesignChange || !design) return { stationId: section.station_id };
|
||||
const send = (change: Partial<CrossDesignChange>) =>
|
||||
onDesignChange(section.chainage_m, {
|
||||
...(design as unknown as CrossDesignChange),
|
||||
...change,
|
||||
});
|
||||
return {
|
||||
stationId: section.station_id,
|
||||
shift: {
|
||||
valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0,
|
||||
set: (side, valueM) => send({ shoulder_extend: { side, valueM } }),
|
||||
},
|
||||
berm: {
|
||||
set: (side, valueM) => send({ berm_depth: { side, valueM } }),
|
||||
// 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움
|
||||
focus: (on) => {
|
||||
if (on) tellBermHost(section.chainage_m);
|
||||
else if (bermHostAt === section.chainage_m) tellBermHost(null);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface DesignLinePick {
|
||||
pieces: DesignPiece[];
|
||||
/** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */
|
||||
@@ -291,6 +391,10 @@ export function attachDesignLinePick(input: {
|
||||
onPick: (index: number) => void;
|
||||
/** 있으면 고른 조각 쪽 사면을 이동 패널로 옮김(139-6 · 151-3). */
|
||||
shift?: ShoulderShift;
|
||||
/** 있으면 고른 소단을 ▲▼ 로 옮김(194-1). */
|
||||
berm?: BermDepthControl;
|
||||
/** 있으면 고름을 조각 키로 기억(194-2 · `pickedLineAt`). */
|
||||
stationId?: string;
|
||||
}): DesignLinePick {
|
||||
const { layer, design, x, toDisplayY } = input;
|
||||
const x0 = x(0);
|
||||
@@ -313,6 +417,7 @@ export function attachDesignLinePick(input: {
|
||||
)
|
||||
.map((el) => pointsOf(el).map(toM));
|
||||
const pieces = visiblePieces(mergeStraightPieces(runs), occluders);
|
||||
if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces));
|
||||
|
||||
// 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김.
|
||||
const firstStructureHit = kids.find((el) =>
|
||||
@@ -337,9 +442,9 @@ export function attachDesignLinePick(input: {
|
||||
label.className = "b06-cross-card__line-dims";
|
||||
label.hidden = true;
|
||||
let side: CrossSide | null = null;
|
||||
let axis: "x" | "y" = "x";
|
||||
let axis: MoveAxis = "x";
|
||||
let active: number | null = null;
|
||||
const { shift } = input;
|
||||
const { shift, berm } = input;
|
||||
|
||||
// 사면 이동 패널(151-3) — B06 공용 조정창 꼴(머리 · 「이동」 줄 십자 · 방향키 ←→).
|
||||
// ✕ = 같은 조각 다시 고름(카드가 풂) · ↺ = 그 쪽 0.
|
||||
@@ -370,8 +475,32 @@ export function attachDesignLinePick(input: {
|
||||
step(1),
|
||||
),
|
||||
);
|
||||
shell.root.append(readout, move.row);
|
||||
const arrows = bindArrowKeys(shell.root, () => [move.controls]);
|
||||
// 소단 높이 줄(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤)
|
||||
const lift = makeRow("높이");
|
||||
lift.controls.classList.add("b06-structure-panel__buttons");
|
||||
const toeZ = (at: CrossSide): number =>
|
||||
Math.min(
|
||||
...pieces
|
||||
.filter((p) => !p.structure && pieceZone(design, p).side === at)
|
||||
.flatMap((p) => [p.from.elevation, p.to.elevation]),
|
||||
);
|
||||
const deepen = (dir: -1 | 1) => () => {
|
||||
if (!side || !berm) return;
|
||||
const now = bermDepthOf(design, side);
|
||||
const next = Math.max(0, Math.round((now + dir * SHIFT_STEP_M) * 10) / 10);
|
||||
const edgeZ = design.road_edges?.[side]?.elevation_m;
|
||||
if (dir > 0 && edgeZ !== undefined && edgeZ - next < toeZ(side) + SHIFT_STEP_M) return;
|
||||
if (next !== now) berm.set(side, next);
|
||||
};
|
||||
lift.controls.append(
|
||||
dpadButton("▲", "up", "고른 소단을 0.1 m 위로(노면 끝보다 높을 수 없음)", deepen(-1)),
|
||||
dpadButton("↺", "reset", "이 쪽 소단 = 구간 기본 깊이", () => {
|
||||
if (side && berm) berm.set(side, design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M);
|
||||
}),
|
||||
dpadButton("▼", "down", "고른 소단을 0.1 m 아래로", deepen(1)),
|
||||
);
|
||||
shell.root.append(readout, move.row, lift.row);
|
||||
const arrows = bindArrowKeys(shell.root, () => [move.controls, lift.controls]);
|
||||
// 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감.
|
||||
// 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김.
|
||||
const blockOffAxis = (event: KeyboardEvent): void => {
|
||||
@@ -402,14 +531,27 @@ export function attachDesignLinePick(input: {
|
||||
label.textContent = piece
|
||||
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
|
||||
: "";
|
||||
const open = !!(piece && shift);
|
||||
const open = !!(piece && (axis === "depth" ? berm : shift));
|
||||
shell.root.classList.toggle("is-hidden", !open);
|
||||
move.row.classList.toggle("is-hidden", axis === "y");
|
||||
move.controls.classList.toggle("is-hidden", axis === "y");
|
||||
// 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단)
|
||||
move.row.classList.toggle("is-hidden", axis !== "x");
|
||||
move.controls.classList.toggle("is-hidden", axis !== "x");
|
||||
lift.row.classList.toggle("is-hidden", axis !== "depth");
|
||||
lift.controls.classList.toggle("is-hidden", axis !== "depth");
|
||||
const sideName = side === "left" ? "(좌)" : "(우)";
|
||||
shell.title.textContent =
|
||||
axis === "y" ? "노면 높이" : side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)";
|
||||
axis === "y"
|
||||
? "노면 높이"
|
||||
: axis === "depth"
|
||||
? `소단 높이 ${sideName}`
|
||||
: `사면 이동 ${sideName}`;
|
||||
readout.textContent =
|
||||
axis === "y" ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" : `이동 ${extend.toFixed(1)}m`;
|
||||
axis === "y"
|
||||
? "방향키 ▲▼ = 종단 계획고(종단도 함께)"
|
||||
: axis === "depth" && side
|
||||
? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m`
|
||||
: `이동 ${extend.toFixed(1)}m`;
|
||||
berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2)
|
||||
arrows.detach();
|
||||
document.removeEventListener("keydown", blockOffAxis);
|
||||
if (open) {
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
appendCrossDesignOverlay,
|
||||
appendPavementOverlay,
|
||||
appendRockBoundaryOverlay,
|
||||
type CrossDesignChange,
|
||||
type RockBoundaryControl,
|
||||
} from "./B06_Section_UI_Cross_Design";
|
||||
import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome";
|
||||
@@ -59,6 +58,7 @@ import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure";
|
||||
import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels";
|
||||
import {
|
||||
attachDesignLinePick,
|
||||
designLineMoves,
|
||||
DESIGN_LINE_ATTR,
|
||||
type DesignLinePick,
|
||||
pickedLineAt,
|
||||
@@ -593,17 +593,8 @@ export function createCrossSectionCard(
|
||||
x,
|
||||
toDisplayY,
|
||||
onPick: pickLine,
|
||||
// 고른 선 쪽 노견 확장(139-6) — 카드 버튼과 같은 창구(세션 · 브라우저 재계산)
|
||||
shift: onDesignChange
|
||||
? {
|
||||
valueOf: (side) => lineDesign[`shoulder_extend_${side}_m`] ?? 0,
|
||||
set: (side, valueM) =>
|
||||
onDesignChange(section.chainage_m, {
|
||||
...(lineDesign as unknown as CrossDesignChange),
|
||||
shoulder_extend: { side, valueM },
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
// 고른 선 쪽 노견 확장(139-6) · 소단 높이(194-1) · 조각 키(194-2) — 카드 버튼과 같은 창구
|
||||
...designLineMoves(section, onDesignChange),
|
||||
});
|
||||
setLineActive = linePick.setActive;
|
||||
setLineActive(pickedLineAt(section.station_id));
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
* - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영]
|
||||
* ========================================================================== */
|
||||
|
||||
import { writeCrossDesignChoice, writeShoulderExtend } from "./B06_Section_Cross_Design_Session";
|
||||
import {
|
||||
writeBermDepth,
|
||||
writeCrossDesignChoice,
|
||||
writeShoulderExtend,
|
||||
} from "./B06_Section_Cross_Design_Session";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
|
||||
import {
|
||||
@@ -74,6 +78,14 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
await reconcileStaleDesigns({ force: true });
|
||||
return;
|
||||
}
|
||||
// 소단 높이(194-1) — 그 쪽 깊이만 세션에 · 캐시에 바로 · 브라우저 재계산(같은 창구)
|
||||
const berm = change.berm_depth;
|
||||
if (berm) {
|
||||
writeBermDepth(projectId, currentRouteId, chainageM, berm.side, berm.valueM);
|
||||
if (target.design) target.design[`berm_depth_${berm.side}_m`] = berm.valueM;
|
||||
await reconcileStaleDesigns({ force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
|
||||
if (target.design) {
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
STRUCTURE_ROW_KEYS,
|
||||
structureAreaRows,
|
||||
} from "./B06_Section_Structure_Layouts";
|
||||
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
|
||||
import {
|
||||
crossDesignChoices,
|
||||
readBermDepths,
|
||||
readShoulderExtends,
|
||||
} from "./B06_Section_Cross_Design_Session";
|
||||
import { readStructureNotes } from "./B06_Section_UI_Cross_Detail";
|
||||
import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table";
|
||||
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
||||
@@ -420,6 +424,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
if (sides.left !== undefined) patch.shoulder_extend_left_m = sides.left;
|
||||
if (sides.right !== undefined) patch.shoulder_extend_right_m = sides.right;
|
||||
});
|
||||
// 소단 쪽마다 옮긴 깊이(194-1) — 서버가 [저장] 때 구조물 정본의 소단 구간 + 이 값으로 다시 냄
|
||||
readBermDepths(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => {
|
||||
const patch = patchFor(chainage);
|
||||
if (sides.left !== undefined) patch.berm_depth_left_m = sides.left;
|
||||
if (sides.right !== undefined) patch.berm_depth_right_m = sides.right;
|
||||
});
|
||||
if (detail) {
|
||||
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다
|
||||
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import type { RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel";
|
||||
import { setBermPickHost } from "./B06_Section_UI_Cross_Design_Pick";
|
||||
import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist";
|
||||
|
||||
/**
|
||||
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그
|
||||
@@ -164,6 +166,28 @@ export function wireStructureSelection(
|
||||
pickBox(born);
|
||||
});
|
||||
|
||||
// 횡단도 소단 수평 단 고름 → 좌측 폼에 그 소단 · 「상세 내용」 상자 켬(194-2 · 191 상자 고름 짝)
|
||||
const BERM_BOX = "fieldset.b05-structure__optgroup";
|
||||
setBermPickHost((chainageM) => {
|
||||
panel.root
|
||||
.querySelectorAll<HTMLElement>(`${BERM_BOX}.is-active`)
|
||||
.forEach((box) => box.classList.remove("is-active"));
|
||||
if (chainageM === null) return;
|
||||
const berm = panel.getStructures().find((item) => {
|
||||
if (item.type_id !== BERM_TYPE_ID) return false;
|
||||
const start = item.start_m ?? item.chainage_m ?? -1;
|
||||
const end = item.end_m ?? item.chainage_m ?? -1;
|
||||
return chainageM >= Math.min(start, end) - 1e-6 && chainageM <= Math.max(start, end) + 1e-6;
|
||||
});
|
||||
if (!berm?.structure_id) return;
|
||||
panel.selectStructure(berm.structure_id);
|
||||
const box = [...panel.root.querySelectorAll<HTMLElement>(BERM_BOX)].find(
|
||||
(el) => !el.closest("[hidden]"),
|
||||
);
|
||||
box?.classList.add("is-active");
|
||||
box?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
const origFord = stationControls.ford.select;
|
||||
stationControls.ford.select = (chainageM, role) => {
|
||||
const nested = wallSelecting;
|
||||
|
||||
@@ -50,6 +50,17 @@ class BermSpec(NamedTuple):
|
||||
interval_m: float = BERM_DEFAULT_INTERVAL_M
|
||||
slope_deg: float = BERM_DEFAULT_SLOPE_DEG
|
||||
depth_m: float = BERM_DEFAULT_DEPTH_M
|
||||
# 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼ · 측점 patch) — None = `depth_m`
|
||||
depth_left_m: float | None = None
|
||||
depth_right_m: float | None = None
|
||||
|
||||
|
||||
def side_berm(berm: BermSpec | None, side: str) -> BermSpec | None:
|
||||
"""그 쪽 소단 — 쪽마다 옮긴 깊이가 있으면 그 값을 `depth_m` 으로(194-1 · 0 미만 = 0)."""
|
||||
if berm is None:
|
||||
return None
|
||||
own = berm.depth_left_m if side == "left" else berm.depth_right_m
|
||||
return berm if own is None else berm._replace(depth_m=max(float(own), 0.0))
|
||||
|
||||
|
||||
def cut_profile_points(
|
||||
|
||||
@@ -39,6 +39,16 @@ export interface BermSpec {
|
||||
intervalM: number;
|
||||
slopeDeg: number;
|
||||
depthM?: number;
|
||||
/** 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼ · 측점 patch) — 없으면 `depthM`. */
|
||||
depthLeftM?: number | null;
|
||||
depthRightM?: number | null;
|
||||
}
|
||||
|
||||
/** 짝: `side_berm`. 그 쪽 소단 — 쪽마다 옮긴 깊이가 있으면 그 값을 `depthM` 으로(0 미만 = 0). */
|
||||
export function sideBerm(berm: BermSpec | null, side: string): BermSpec | null {
|
||||
if (berm === null) return null;
|
||||
const own = side === "left" ? berm.depthLeftM : berm.depthRightM;
|
||||
return own === undefined || own === null ? berm : { ...berm, depthM: Math.max(own, 0) };
|
||||
}
|
||||
|
||||
export function bermSpec(
|
||||
|
||||
@@ -143,6 +143,9 @@ export interface CrossDesignResult {
|
||||
/** 노견 확장(m · 139-6) — 넓힌 쪽만. */
|
||||
shoulder_extend_left_m?: number;
|
||||
shoulder_extend_right_m?: number;
|
||||
/** 소단 쪽마다 옮긴 깊이(m · 194-1) — 옮긴 쪽만. */
|
||||
berm_depth_left_m?: number;
|
||||
berm_depth_right_m?: number;
|
||||
cross_slope_pct: number;
|
||||
ditch: Record<string, unknown>;
|
||||
/** 결과 — **실제로 섰나**. */
|
||||
@@ -588,6 +591,12 @@ export function computeCrossDesign(
|
||||
slope_deg: round4(options.berm.slopeDeg),
|
||||
depth_m: round4(Math.max(options.berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0)),
|
||||
};
|
||||
// 쪽마다 옮긴 깊이(194-1) — 옮긴 쪽만 되싣는다(짝: 파이썬)
|
||||
const { depthLeftM, depthRightM } = options.berm;
|
||||
if (depthLeftM !== undefined && depthLeftM !== null)
|
||||
result.berm_depth_left_m = round4(Math.max(depthLeftM, 0));
|
||||
if (depthRightM !== undefined && depthRightM !== null)
|
||||
result.berm_depth_right_m = round4(Math.max(depthRightM, 0));
|
||||
}
|
||||
// 노견 확장(139-6) — 넓힌 쪽만 되싣는다(짝: 파이썬)
|
||||
if (geometry.shoulderExtendLeft > 0)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
cutProfilePoints,
|
||||
elevationAt,
|
||||
fillProfilePoints,
|
||||
sideBerm,
|
||||
} from "./common_util_cross_berm";
|
||||
|
||||
/** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */
|
||||
@@ -306,7 +307,7 @@ export class SectionGeometry {
|
||||
startDist,
|
||||
startZ,
|
||||
this.fillRatio,
|
||||
this.berm,
|
||||
sideBerm(this.berm, side), // 쪽마다 옮긴 깊이(194-1)
|
||||
undefined,
|
||||
edgeZ,
|
||||
);
|
||||
|
||||
@@ -3573,6 +3573,7 @@
|
||||
"weep_hole_area_m2": false
|
||||
},
|
||||
"berm": {
|
||||
"depth_m": false,
|
||||
"height_m": false,
|
||||
"length_m": false,
|
||||
"before_m": false,
|
||||
@@ -3807,8 +3808,7 @@
|
||||
"id": "gbermdetail",
|
||||
"이름": "상세 내용",
|
||||
"옵션": [
|
||||
"width_m",
|
||||
"depth_m"
|
||||
"width_m"
|
||||
],
|
||||
"형상": {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/* 194-1 · 194-2 소단 고름 헬퍼 — `_Cross_Design_Pick` 을 그 자리에서 트랜스파일해 흉내 DOM 으로 돌림.
|
||||
* 소단 = 높이만(▲▼ · ◀▶ 막힘 · 노면 끝 위 금지) · 조각 키로 다시 그려도 같은 조각 · 좌측 상자 알림. 짝 = `test_194_1_berm_depth.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
class Node {
|
||||
constructor(tag) {
|
||||
this.tagName = tag;
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.listeners = {};
|
||||
this.hidden = false;
|
||||
this.isConnected = true;
|
||||
this._text = "";
|
||||
this._cls = new Set();
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...n) => n.forEach((c) => self._cls.add(c)),
|
||||
toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)),
|
||||
contains: (c) => self._cls.has(c),
|
||||
};
|
||||
}
|
||||
get descendants() {
|
||||
return this.children.flatMap((c) => [c, ...c.descendants]);
|
||||
}
|
||||
/** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */
|
||||
querySelector(sel) {
|
||||
return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null;
|
||||
}
|
||||
getClientRects() {
|
||||
return this._cls.has("is-hidden") ? [] : [{}];
|
||||
}
|
||||
set className(v) {
|
||||
this._cls = new Set(String(v).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
set textContent(v) {
|
||||
this._text = String(v);
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
insertBefore(kid) {
|
||||
this.children.push(kid);
|
||||
}
|
||||
setAttribute(n, v) {
|
||||
this.attrs[n] = String(v);
|
||||
if (n === "class") this.className = v;
|
||||
}
|
||||
getAttribute(n) {
|
||||
return this.attrs[n] ?? null;
|
||||
}
|
||||
hasAttribute(n) {
|
||||
return n in this.attrs;
|
||||
}
|
||||
addEventListener(t, fn) {
|
||||
(this.listeners[t] ??= []).push(fn);
|
||||
}
|
||||
click() {
|
||||
(this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined }));
|
||||
}
|
||||
}
|
||||
const docListeners = {};
|
||||
globalThis.document = {
|
||||
createElement: (t) => new Node(t),
|
||||
createElementNS: (_n, t) => new Node(t),
|
||||
addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn),
|
||||
removeEventListener: (t, fn) => {
|
||||
docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn);
|
||||
},
|
||||
};
|
||||
const key = (name) =>
|
||||
[...(docListeners.keydown ?? [])].forEach((fn) =>
|
||||
fn({ key: name, target: null, preventDefault: () => undefined }),
|
||||
);
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const req = (r) =>
|
||||
r.startsWith("@util/")
|
||||
? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`))
|
||||
: loadTs(path.join(path.dirname(full), `${r}.ts`));
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
globalThis.Element = Node;
|
||||
|
||||
const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts"));
|
||||
const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽
|
||||
const y = (e) => 900 - (e - 100) * 12;
|
||||
const line = (pts, structure = false) => {
|
||||
const el = new Node("polyline");
|
||||
el.setAttribute("class", "b06-chart__design-cross");
|
||||
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
|
||||
if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, "");
|
||||
return el;
|
||||
};
|
||||
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
|
||||
const press = (name) => {
|
||||
let prevented = false;
|
||||
[...(docListeners.keydown ?? [])].forEach((fn) =>
|
||||
fn({ key: name, target: null, preventDefault: () => (prevented = true) }),
|
||||
);
|
||||
return prevented;
|
||||
};
|
||||
|
||||
/** 양성토 단면(노면 끝 표고 100) — 좌 소단 깊이 `dl`(노면 끝 아래) · 우 1.0 · 사면 1:1.5 · 끝 표고 97. `order` = 그리는 차례. */
|
||||
function card({ dl = 1, stored = undefined, order = ["left", "road", "right"], stationId } = {}) {
|
||||
for (const k of Object.keys(docListeners)) if (k === "keydown") docListeners[k] = [];
|
||||
const design = {
|
||||
road_edges: {
|
||||
left: { offset_m: 2, elevation_m: 100 },
|
||||
right: { offset_m: -2, elevation_m: 100 },
|
||||
},
|
||||
ditch: null,
|
||||
ditch_side: null,
|
||||
ditch_enabled: false,
|
||||
section_mode: "both_fill",
|
||||
berm: { width_m: 0.5, interval_m: 3, slope_deg: 0, depth_m: 1 },
|
||||
};
|
||||
if (stored !== undefined) design.berm_depth_left_m = stored;
|
||||
const slope = (s, d) => {
|
||||
const k = s === "left" ? 1 : -1;
|
||||
const knee = 2 + 1.5 * d;
|
||||
const toe = knee + 0.5 + 1.5 * (100 - d - 97);
|
||||
return [
|
||||
[k * toe, 97],
|
||||
[k * (knee + 0.5), 100 - d],
|
||||
[k * knee, 100 - d],
|
||||
[k * 2, 100],
|
||||
];
|
||||
};
|
||||
const lines = {
|
||||
left: line(slope("left", dl)),
|
||||
road: line([
|
||||
[2, 100],
|
||||
[0, 100.04], // 노면 횡단경사 2 % — 깊이 0 소단이 노면과 한 직선으로 안 합쳐짐
|
||||
[-2, 100],
|
||||
]),
|
||||
right: line(slope("right", 1)),
|
||||
};
|
||||
const layer = new Node("g");
|
||||
layer.append(...order.map((name) => lines[name]));
|
||||
const values = { left: 0, right: 0 };
|
||||
const sets = [];
|
||||
const focus = [];
|
||||
const handle = pick.attachDesignLinePick({
|
||||
layer,
|
||||
design,
|
||||
x,
|
||||
toDisplayY: y,
|
||||
onPick: () => undefined,
|
||||
shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) },
|
||||
berm: { set: (s, v) => sets.push([s, v]), focus: (on) => focus.push(on) },
|
||||
stationId,
|
||||
});
|
||||
const find = (test) => handle.pieces.findIndex(test);
|
||||
const mid = (p) => (p.from.offset + p.to.offset) / 2;
|
||||
const at = {
|
||||
leftBerm: find((p) => pick.isBermPiece(design, p) && mid(p) > 0),
|
||||
rightBerm: find((p) => pick.isBermPiece(design, p) && mid(p) < 0),
|
||||
leftUpper: find((p) => !pick.isBermPiece(design, p) && mid(p) > 2 && mid(p) < 2 + 1.5 * dl),
|
||||
road: find((p) => mid(p) > 0 && mid(p) < 2),
|
||||
};
|
||||
const slot = (name) =>
|
||||
handle.panel.descendants.find(
|
||||
(n) => n._cls.has(`b06-structure-panel__btn--${name}`) && !n.parentHidden,
|
||||
);
|
||||
const rows = () =>
|
||||
handle.panel.descendants
|
||||
.filter((n) => n._cls.has("b06-structure-panel__struct"))
|
||||
.map((n) => n._cls.has("is-hidden"));
|
||||
const text = (cls) => handle.panel.descendants.find((n) => n._cls.has(cls)).textContent;
|
||||
return { design, handle, values, sets, focus, at, slot, rows, text };
|
||||
}
|
||||
|
||||
const out = {};
|
||||
|
||||
// ① 갈래 · 축 — 소단 = depth · 사면 = x · 노면 = y · 키
|
||||
{
|
||||
const c = card();
|
||||
out.axis = Object.fromEntries(
|
||||
Object.entries(c.at).map(([name, i]) => [
|
||||
name,
|
||||
pick.pieceMoveAxis(c.design, c.handle.pieces[i]),
|
||||
]),
|
||||
);
|
||||
out.keys = pick.pieceKeys(c.design, c.handle.pieces).sort();
|
||||
}
|
||||
|
||||
// ② 소단 고름 — ▲▼ 만 · ◀▶ 막힘 · ↺ = 구간 기본 · 좌측 상자 켬 알림
|
||||
{
|
||||
const c = card();
|
||||
c.handle.setActive(c.at.leftBerm);
|
||||
const [moveHidden, liftHidden] = c.rows();
|
||||
const leftBlocked = press("ArrowLeft");
|
||||
const rightBlocked = press("ArrowRight");
|
||||
const afterSide = { values: { ...c.values }, sets: c.sets.length };
|
||||
const upTaken = press("ArrowUp");
|
||||
press("ArrowDown");
|
||||
c.handle.panel.descendants
|
||||
.filter((n) => n._cls.has("b06-structure-panel__btn--reset"))
|
||||
.at(-1)
|
||||
.click();
|
||||
out.berm = {
|
||||
title: c.text("b06-structure-panel__title"),
|
||||
readout: c.text("b06-structure-panel__value"),
|
||||
moveHidden,
|
||||
liftHidden,
|
||||
leftBlocked,
|
||||
rightBlocked,
|
||||
afterSide,
|
||||
upTaken,
|
||||
sets: c.sets,
|
||||
focus: c.focus,
|
||||
};
|
||||
c.handle.setActive(c.at.leftUpper); // 사면으로 바꿈 — 소단 상자 끔 · ▲▼ 막힘 · 깊이 안 바뀜
|
||||
const before = c.sets.length;
|
||||
out.berm.slopeUpBlocked = press("ArrowUp");
|
||||
out.berm.slopeSets = c.sets.length - before;
|
||||
out.berm.slopeTitle = c.text("b06-structure-panel__title");
|
||||
out.berm.focusAfter = c.focus.at(-1);
|
||||
// 노면 = ▲▼ 는 종단도로 넘김(안 막음)
|
||||
c.handle.setActive(c.at.road);
|
||||
out.berm.roadUpPassed = !press("ArrowUp");
|
||||
out.berm.roadSets = c.sets.length - before;
|
||||
}
|
||||
|
||||
// ③ 도로 위 금지 = 0 에서 멈춤 · 사면 끝 앞에서 멈춤
|
||||
{
|
||||
const top = card({ dl: 0, stored: 0 });
|
||||
top.handle.setActive(top.at.leftBerm);
|
||||
press("ArrowUp");
|
||||
const deep = card({ dl: 2.9, stored: 2.9 });
|
||||
deep.handle.setActive(deep.at.leftBerm);
|
||||
press("ArrowDown"); // 2.9 → 3.0 = 사면 끝(97) — 막힘
|
||||
press("ArrowUp");
|
||||
out.limit = { topSets: top.sets, topFound: top.at.leftBerm >= 0, deepSets: deep.sets };
|
||||
}
|
||||
|
||||
// ④ 옮긴 뒤 다시 그려도 같은 조각(194-2) — 그리는 차례 · 깊이가 바뀌어도
|
||||
{
|
||||
const first = card({ stationId: "S1" });
|
||||
pick.setPickedLine("S1", first.at.leftBerm);
|
||||
const again = card({
|
||||
dl: 1.1,
|
||||
stored: 1.1,
|
||||
order: ["right", "road", "left"],
|
||||
stationId: "S1",
|
||||
});
|
||||
const restored = pick.pickedLineAt("S1");
|
||||
pick.setPickedLine("S1", again.at.leftUpper);
|
||||
const third = card({ dl: 1.2, stored: 1.2, stationId: "S1" });
|
||||
out.stable = {
|
||||
firstIndex: first.at.leftBerm,
|
||||
againIndex: again.at.leftBerm,
|
||||
restored,
|
||||
upperRestored: pick.pickedLineAt("S1") === third.at.leftUpper,
|
||||
upperIndexMoved: again.at.leftUpper !== third.at.leftUpper,
|
||||
};
|
||||
pick.setPickedLine("S1", null);
|
||||
}
|
||||
|
||||
// ⑤ 카드 창구 — 소단 깊이 변경 · 좌측 상자 알림(한 번만 · 이 측점이 켠 때만 끔)
|
||||
{
|
||||
const changes = [];
|
||||
const design = { ground_type: "soil", section_mode: "both_fill", shoulder_extend_left_m: 0.3 };
|
||||
const moves = pick.designLineMoves({ station_id: "S9", chainage_m: 40, design }, (ch, change) =>
|
||||
changes.push([ch, change]),
|
||||
);
|
||||
moves.berm.set("left", 1.3);
|
||||
const host = [];
|
||||
pick.setBermPickHost((ch) => host.push(ch));
|
||||
moves.berm.focus(true);
|
||||
moves.berm.focus(true); // 다시 그린 카드 — 안 부름
|
||||
pick
|
||||
.designLineMoves({ station_id: "S8", chainage_m: 20, design }, () => undefined)
|
||||
.berm.focus(false);
|
||||
moves.berm.focus(false);
|
||||
out.moves = {
|
||||
stationId: moves.stationId,
|
||||
shiftValue: moves.shift.valueOf("left"),
|
||||
change: changes[0],
|
||||
host,
|
||||
none: pick.designLineMoves({ station_id: "S7", chainage_m: 0, design }),
|
||||
};
|
||||
pick.setBermPickHost(null);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -199,7 +199,9 @@ def test_저장분_깊이_읽기():
|
||||
|
||||
|
||||
def test_좌측_패널_칸_구간_깊이_나름():
|
||||
"""구조물 옵션 depth_m → 세션 구간 → 측점 제원 · 서버 payload(같은 꼴)."""
|
||||
"""옛 구조물 옵션 depth_m → 세션 구간 → 측점 제원(기본값) · 서버 payload(같은 꼴).
|
||||
|
||||
좌측 칸은 194-1 에서 숨김 — 옮기기는 횡단도 ▲▼(`test_194_1_berm_depth`)."""
|
||||
source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""194-1 · 194-2 소단 = 좌측 위치 칸 없앰 · 횡단도에서 고른 소단을 ▲▼ 로만 옮김.
|
||||
|
||||
측점 · 쪽마다 깊이(`berm_depth_{side}_m`).
|
||||
|
||||
- 좌측 칸 없음(정의 그룹 · 표시) · 기본 = 노면 끝 아래 1 m
|
||||
- 짝 거울: `common_util_cross_berm` · 설계 기하(파이썬 ↔ TS) — 쪽마다 깊이(`berm_depth_{side}_m`)
|
||||
- 서버 [저장] 다시 셈: 구조물 정본 소단 구간 + 측점 patch 깊이(옛 구간 depth_m = 기본값)
|
||||
- 화면(흉내 DOM · `helper_194_1_berm_pick.cjs`): ▲▼ 만 · ◀▶ 막힘 · 도로 위 금지 ·
|
||||
옮긴 뒤 같은 조각 ·
|
||||
좌측 소단 상자 알림
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
USER_TOUCHED_KEYS,
|
||||
berm_from,
|
||||
enforce_design_lines,
|
||||
stored_berm,
|
||||
)
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch
|
||||
from common_util.common_util_cross_berm import BermSpec, side_berm
|
||||
from config.config_system import STANDARD_CROSS_SECTION
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
HELPER = Path(__file__).parent / "helper_194_1_berm_pick.cjs"
|
||||
DEF = ROOT / "resources" / "master_template" / "구조물정의.json"
|
||||
|
||||
node_only = pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
|
||||
|
||||
def _ground() -> list[dict]:
|
||||
"""양성토 — 지반이 노면 끝보다 3 m 이상 낮음."""
|
||||
return [
|
||||
{"offset_m": o / 2, "elevation_m": round(96.0 + 0.02 * o / 2, 4), "valid": True}
|
||||
for o in range(-40, 41)
|
||||
]
|
||||
|
||||
|
||||
def _berm(left=None, right=None, depth=1.0) -> BermSpec:
|
||||
return BermSpec(0.5, 3.0, 0.0, depth, left, right)
|
||||
|
||||
|
||||
CASES = [
|
||||
{"name": "양쪽 기본 1.0", "berm": _berm()},
|
||||
{"name": "좌 2.0 · 우 기본", "berm": _berm(left=2.0)},
|
||||
{"name": "좌 0(노면 끝) · 우 0.5", "berm": _berm(left=0.0, right=0.5)},
|
||||
{"name": "좌 음수 = 0", "berm": _berm(left=-1.0)},
|
||||
]
|
||||
|
||||
|
||||
def _python(berm: BermSpec) -> dict:
|
||||
return compute_cross_design(
|
||||
_ground(), 100.0, standard=STANDARD_CROSS_SECTION, ground_type="soil",
|
||||
section_mode="both_fill", berm=berm,
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def _flat_z(design: dict, side: str) -> list[float]:
|
||||
"""그 쪽 노면 끝 밖 평탄 도막(소단) 표고 — 지반(≤ 96.4)에 닿기 전 사면만."""
|
||||
edge = design["road_edges"][side]["offset_m"]
|
||||
sign = 1 if side == "left" else -1
|
||||
line = sorted(
|
||||
(
|
||||
p
|
||||
for p in design["design_line"]
|
||||
if sign * p["offset_m"] >= sign * edge - 1e-6 and p["elevation_m"] > 96.5
|
||||
),
|
||||
key=lambda p: sign * p["offset_m"],
|
||||
)
|
||||
flats = {
|
||||
round(a["elevation_m"], 3) # 설계선 표본이 한 단을 여러 도막으로 끊어도 한 표고
|
||||
for a, b in zip(line, line[1:], strict=False)
|
||||
if abs(b["offset_m"] - a["offset_m"]) > 1e-6
|
||||
and abs(b["elevation_m"] - a["elevation_m"]) / abs(b["offset_m"] - a["offset_m"]) < 0.05
|
||||
}
|
||||
return sorted(flats)
|
||||
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { computeCrossDesign } from "./common_util_cross_design.js";
|
||||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const designs = input.cases.map((c) => computeCrossDesign(input.samples, 100, {
|
||||
standard: input.standard, groundType: "soil", sectionMode: "both_fill",
|
||||
berm: { widthM: c[0], intervalM: c[1], slopeDeg: c[2], depthM: c[3],
|
||||
depthLeftM: c[4], depthRightM: c[5] },
|
||||
}));
|
||||
writeFileSync(process.argv[3], JSON.stringify(designs));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts_designs(tmp_path_factory) -> list[dict]:
|
||||
tmp = tmp_path_factory.mktemp("berm194")
|
||||
out = tmp / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(TSC), str(ROOT / "common_util" / "common_util_cross_design.ts"),
|
||||
"--outDir", str(out), "--module", "esnext", "--target", "es2022",
|
||||
"--moduleResolution", "bundler", "--ignoreConfig"],
|
||||
cwd=str(ROOT), check=True, capture_output=True,
|
||||
) # fmt: skip
|
||||
for emitted in out.glob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
src, dst = tmp / "in.json", tmp / "out.json"
|
||||
src.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cases": [list(case["berm"]) for case in CASES],
|
||||
"samples": _ground(),
|
||||
"standard": STANDARD_CROSS_SECTION,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
|
||||
)
|
||||
return json.loads(dst.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _close(left, right, path=""):
|
||||
if isinstance(left, dict):
|
||||
assert set(left) == set(right), f"{path} 키 다름 {set(left) ^ set(right)}"
|
||||
for key in left:
|
||||
_close(left[key], right[key], f"{path}.{key}")
|
||||
elif isinstance(left, list):
|
||||
assert len(left) == len(right), f"{path} 길이"
|
||||
for i, (a, b) in enumerate(zip(left, right, strict=True)):
|
||||
_close(a, b, f"{path}[{i}]")
|
||||
elif isinstance(left, (int, float)) and not isinstance(left, bool):
|
||||
assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}"
|
||||
else:
|
||||
assert left == right, f"{path}: {left!r} vs {right!r}"
|
||||
|
||||
|
||||
# ── 좌측 칸 없음 · 기본 1 m ────────────────────────────────────────────────
|
||||
def test_좌측_패널_깊이_칸_없음() -> None:
|
||||
data = json.loads(DEF.read_text(encoding="utf-8"))
|
||||
assert data["그룹"]["berm"][0]["옵션"] == ["width_m"]
|
||||
assert data["표시"]["berm"]["depth_m"] is False
|
||||
berm = next(t for t in data["types"] if t["type_id"] == "berm")
|
||||
depth = next(o for o in berm["options"] if o["key"] == "depth_m")
|
||||
assert depth["default"] == 1.0 # 옛 구간 값 · 새 구간 기본 = 노면 끝 아래 1 m
|
||||
|
||||
|
||||
def test_기본_1m_수평_한_단() -> None:
|
||||
design = _python(_berm())
|
||||
for side in ("left", "right"):
|
||||
edge_z = design["road_edges"][side]["elevation_m"]
|
||||
assert _flat_z(design, side) == [pytest.approx(edge_z - 1.0, abs=1e-3)]
|
||||
assert "berm_depth_left_m" not in design and "berm_depth_right_m" not in design
|
||||
|
||||
|
||||
# ── 짝 거울 · 쪽마다 깊이 ───────────────────────────────────────────────────
|
||||
def test_쪽마다_깊이_그_쪽만() -> None:
|
||||
by = {case["name"]: _python(case["berm"]) for case in CASES}
|
||||
two = by["좌 2.0 · 우 기본"]
|
||||
assert _flat_z(two, "left") == [pytest.approx(two["road_edges"]["left"]["elevation_m"] - 2.0)]
|
||||
assert _flat_z(two, "right") == [pytest.approx(two["road_edges"]["right"]["elevation_m"] - 1.0)]
|
||||
assert two["berm_depth_left_m"] == 2.0 and "berm_depth_right_m" not in two
|
||||
assert two["berm"]["depth_m"] == 1.0 # 구간 값은 그대로
|
||||
top = by["좌 0(노면 끝) · 우 0.5"]
|
||||
assert _flat_z(top, "left") == [pytest.approx(top["road_edges"]["left"]["elevation_m"])]
|
||||
neg = by["좌 음수 = 0"]
|
||||
assert neg["berm_depth_left_m"] == 0.0 # 도로보다 높을 수 없음
|
||||
assert neg["design_line"] == _python(_berm(left=0.0))["design_line"]
|
||||
assert side_berm(_berm(right=1.5), "right").depth_m == 1.5
|
||||
assert side_berm(_berm(right=1.5), "left").depth_m == 1.0
|
||||
|
||||
|
||||
@node_only
|
||||
def test_파이썬_TS_거울(ts_designs: list[dict]) -> None:
|
||||
for case, ts in zip(CASES, ts_designs, strict=True):
|
||||
_close(_python(case["berm"]), ts, case["name"])
|
||||
|
||||
|
||||
def test_짝_머리_표시() -> None:
|
||||
for name in ("common_util_cross_berm.py", "common_util_cross_berm.ts"):
|
||||
text = (ROOT / "common_util" / name).read_text(encoding="utf-8")
|
||||
assert "짝" in text and ("side_berm" in text or "sideBerm" in text)
|
||||
|
||||
|
||||
# ── 측점 patch 저장 왕복 · 서버 다시 셈 ─────────────────────────────────────
|
||||
def test_patch_칸_왕복() -> None:
|
||||
patch = CrossSectionPatch(chainage_m=40, berm_depth_left_m=1.5, berm_depth_right_m=0)
|
||||
dumped = {k: v for k, v in patch.model_dump().items() if v is not None}
|
||||
assert dumped == {"chainage_m": 40, "berm_depth_left_m": 1.5, "berm_depth_right_m": 0}
|
||||
with pytest.raises(ValidationError):
|
||||
CrossSectionPatch(chainage_m=40, berm_depth_left_m=-0.1) # 도로 위 금지
|
||||
assert {"berm_depth_left_m", "berm_depth_right_m"} <= set(USER_TOUCHED_KEYS)
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
assert '"berm_depth_left_m",' in refresh and '"berm_depth_right_m",' in refresh
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert "patch.berm_depth_left_m = sides.left" in persist
|
||||
|
||||
|
||||
def test_저장분_읽기_옛_구간값_기본() -> None:
|
||||
old = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}})
|
||||
assert (old.depth_m, old.depth_left_m, old.depth_right_m) == (2.5, None, None)
|
||||
moved = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}, "berm_depth_left_m": 1.2})
|
||||
assert (moved.depth_left_m, moved.depth_right_m) == (1.2, None)
|
||||
# 프리뷰 payload(구간 제원 + 쪽 깊이)
|
||||
spec = berm_from({"width_m": 0.5, "depth_m": 1.0, "berm_depth_right_m": -3}, {
|
||||
"berm_depth_right_m": -3
|
||||
}) # fmt: skip
|
||||
assert spec.depth_right_m == 0.0
|
||||
|
||||
|
||||
LONG = {
|
||||
"design_profiles": [
|
||||
{
|
||||
"samples": [
|
||||
{"chainage_m": 0, "elevation_m": 100},
|
||||
{"chainage_m": 200, "elevation_m": 100},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _project(tmp_path: Path, structures: list[dict]) -> Path:
|
||||
route = tmp_path / "B05_Profile" / "route"
|
||||
route.mkdir(parents=True)
|
||||
(route / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
BERM_SPAN = {
|
||||
"type_id": "berm",
|
||||
"placement": "interval",
|
||||
"start_m": 0,
|
||||
"end_m": 100,
|
||||
"options": {"width_m": 0.5, "depth_m": 2.5}, # 옛 좌측 칸 값 = 기본값
|
||||
}
|
||||
|
||||
|
||||
def test_저장_때_서버가_정본_구간_측점_깊이로_다시_셈(tmp_path: Path) -> None:
|
||||
root = _project(tmp_path, [BERM_SPAN])
|
||||
browser = compute_cross_design(_ground(), 100.0, ground_type="soil", section_mode="both_fill")
|
||||
# 브라우저 patch = 사용자 값(좌 깊이)만 · 설계선은 소단 없는 옛 것
|
||||
stored = {**browser, "berm_depth_left_m": 1.5}
|
||||
sections = [{"chainage_m": 40.0, "samples": _ground(), "design": stored}]
|
||||
assert enforce_design_lines(LONG, sections, root) == 1
|
||||
design = sections[0]["design"]
|
||||
assert design["berm"]["depth_m"] == 2.5 and design["berm_depth_left_m"] == 1.5
|
||||
left_z = design["road_edges"]["left"]["elevation_m"]
|
||||
right_z = design["road_edges"]["right"]["elevation_m"]
|
||||
assert _flat_z(design, "left") == [pytest.approx(left_z - 1.5, abs=1e-3)]
|
||||
assert _flat_z(design, "right") == [pytest.approx(right_z - 2.5, abs=1e-3)]
|
||||
# 구간 밖 측점 = 소단 없음 · 옮긴 값만 남음(다시 놓으면 그대로 씀)
|
||||
outside = [{"chainage_m": 150.0, "samples": _ground(), "design": dict(stored)}]
|
||||
enforce_design_lines(LONG, outside, root)
|
||||
assert "berm" not in outside[0]["design"] and _flat_z(outside[0]["design"], "left") == []
|
||||
|
||||
|
||||
def test_구간_지우면_저장분_소단도_사라짐(tmp_path: Path) -> None:
|
||||
root = _project(tmp_path, [])
|
||||
old = _python(_berm(left=1.5))
|
||||
sections = [{"chainage_m": 40.0, "samples": _ground(), "design": old}]
|
||||
enforce_design_lines(LONG, sections, root)
|
||||
assert "berm" not in sections[0]["design"]
|
||||
assert _flat_z(sections[0]["design"], "left") == []
|
||||
|
||||
|
||||
# ── 화면 고름(흉내 DOM) ─────────────────────────────────────────────────────
|
||||
@pytest.fixture(scope="module")
|
||||
def ui() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
@node_only
|
||||
def test_이동_셋_분리(ui: dict) -> None:
|
||||
assert ui["axis"] == {"leftBerm": "depth", "rightBerm": "depth", "leftUpper": "x", "road": "y"}
|
||||
assert "berm:left:0" in ui["keys"] and "fill:left:1" in ui["keys"]
|
||||
|
||||
|
||||
@node_only
|
||||
def test_소단_고름_위아래만(ui: dict) -> None:
|
||||
berm = ui["berm"]
|
||||
assert berm["title"] == "소단 높이 (좌)" and berm["readout"] == "도로 아래 1.0m"
|
||||
assert berm["moveHidden"] is True and berm["liftHidden"] is False
|
||||
assert berm["leftBlocked"] and berm["rightBlocked"] # ◀▶ 막힘
|
||||
assert berm["afterSide"] == {"values": {"left": 0, "right": 0}, "sets": 0} # 가로 안 감
|
||||
assert berm["upTaken"]
|
||||
assert berm["sets"] == [["left", 0.9], ["left", 1.1], ["left", 1]] # ▲ · ▼ · ↺ 기본
|
||||
assert berm["slopeTitle"] == "사면 이동 (좌)" and berm["slopeUpBlocked"]
|
||||
assert berm["slopeSets"] == 0 and berm["roadSets"] == 0
|
||||
assert berm["roadUpPassed"] # 노면 ▲▼ = 종단도 몫
|
||||
|
||||
|
||||
@node_only
|
||||
def test_도로_위_금지_사면_끝_앞_멈춤(ui: dict) -> None:
|
||||
assert ui["limit"]["topFound"] and ui["limit"]["topSets"] == [] # 깊이 0 에서 ▲ = 안 감
|
||||
assert ui["limit"]["deepSets"] == [["left", 2.8]] # ▼ 막힘 · ▲ 는 감
|
||||
|
||||
|
||||
@node_only
|
||||
def test_옮긴_뒤_같은_조각(ui: dict) -> None:
|
||||
stable = ui["stable"]
|
||||
assert stable["firstIndex"] != stable["againIndex"] # 번호는 바뀜
|
||||
assert stable["restored"] == stable["againIndex"] # 키로 같은 소단
|
||||
assert stable["upperRestored"] and stable["upperIndexMoved"]
|
||||
|
||||
|
||||
@node_only
|
||||
def test_좌측_소단_상자_알림_카드_창구(ui: dict) -> None:
|
||||
assert ui["berm"]["focus"][0] is True and ui["berm"]["focusAfter"] is False
|
||||
moves = ui["moves"]
|
||||
assert moves["stationId"] == "S9" and moves["shiftValue"] == 0.3
|
||||
chainage, change = moves["change"]
|
||||
assert chainage == 40 and change["berm_depth"] == {"side": "left", "valueM": 1.3}
|
||||
assert change["ground_type"] == "soil" # 카드 버튼 값은 그대로 실음
|
||||
assert moves["host"] == [40, None] # 한 번만 켬 · 다른 측점 끔은 무시
|
||||
assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 패널 없음
|
||||
selection = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Selection.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "setBermPickHost(" in selection and 'classList.add("is-active")' in selection
|
||||
@@ -28,7 +28,11 @@ TABLE = {
|
||||
"옹벽",
|
||||
["wall_form", "length_m", "height_m", "before_m", "after_m"],
|
||||
),
|
||||
"berm": ("gbermdetail", "상세 내용", ["width_m", "depth_m"]), # 높이 칸 숨김 · 깊이(178-2)
|
||||
"berm": (
|
||||
"gbermdetail",
|
||||
"상세 내용",
|
||||
["width_m"],
|
||||
), # 높이 · 깊이 칸 숨김(깊이 = 횡단도 ▲▼ · 194-1)
|
||||
}
|
||||
SOIL_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user