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

This commit is contained in:
2026-09-07 16:09:54 +09:00
21 changed files with 816 additions and 100 deletions
+27 -1
View File
@@ -109,6 +109,10 @@ export const STATE_REGISTRY = {
culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` },
/** 암 경계선 오프셋(측점별). */
rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` },
/** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9.
* 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을
* 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */
berm: { bucket: "draft", scope: "route" },
/** 표준 횡단면 설정 패널의 편집값.
*
* ⚠ **[저장]·[확정] 뒤에도 지우지 않는다**(2026-09-07 확인). 다른 초안과 달리 이 값은
@@ -405,6 +409,27 @@ export function clearState(
projectId?: string | null,
routeId?: number | string | null,
): void {
const entry = entryOf(name);
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다.
// 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`),
// 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169
// 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 —
// 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다.
if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) {
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
try {
const doomed: string[] = [];
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
if (key && key.startsWith(prefix)) doomed.push(key);
}
doomed.forEach((key) => writeRaw(key, null));
} catch {
/* 무시 — 저장소가 막힌 브라우저 */
}
return;
}
writeStateRaw(name, null, projectId, routeId);
}
@@ -417,7 +442,8 @@ export function namesInBucket(bucket: StateBucket): StateName[] {
/**
* 초안을 통째로 비운다 — **[저장]·[확정]·[초기화]·노선 변경 뒤 여기 한 곳**만 부른다
* (예전에는 파일마다 따로 지웠다). 노선을 모르면 노선 범위 초안은 남는다.
* (예전에는 파일마다 따로 지웠다). 노선을 안 주면 그 프로젝트의 **모든 노선** 초안이 나간다
* (노선 변경 자리가 그렇게 부른다 — 새 번호를 아직 모르기 때문).
*/
export function clearDrafts(projectId: string | null, routeId?: number | string | null): void {
namesInBucket("draft").forEach((name) => clearState(name, projectId, routeId));
+3 -1
View File
@@ -48,6 +48,9 @@ export interface StructureType {
drawing_views: string[];
/** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */
managed_by: string | null;
/** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름.
* 측구(옆도랑) = `"횡단 설계"`. 항목에 「~에서 관리」를 붙이고 수량 집계는 건너뛴다. */
design_owner: string | null;
reference_only: boolean;
enabled: boolean;
}
@@ -212,7 +215,6 @@ export function pipesToStructureMarks(
})) as StructureInstance[];
}
export function structureAnchorM(structure: StructureInstance): number {
return structure.chainage_m ?? structure.start_m ?? 0;
}
@@ -448,6 +448,7 @@
"enabled": false,
"group": "B",
"name": "측구(옆도랑)",
"design_owner": "횡단 설계",
"placement": "interval",
"style": {
"color": "#2eaadc",
@@ -65,6 +65,14 @@ class StructureType(BaseModel):
drawing_views: list[str] = Field(default_factory=list)
# 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다.
managed_by: str | None = None
# 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다
# (2026-09-07 사용자: 「두되 표시만 해줘」). `managed_by`와 달리 저장은 그대로 되고,
# ① 화면이 「{이름}에서 관리」 표시를 붙이고 ② 수량 집계가 건너뛴다.
#
# 측구(옆도랑)가 그 경우다 — 횡단 설계가 측구 켬/끔·형식·터파기 단면적을 이미 셈하므로
# (`B06_Section_Engine_Design.py` · `common_util_cross_design.ts` 짝), 구조물로 또 세면
# **같은 것을 두 번 계상**한다(2026-09-07 조사).
design_owner: str | None = None
# 전문 상세설계가 따로 필요한 시설(교량 등) — 배치·제원 입력까지만 담당한다.
reference_only: bool = False
enabled: bool = True
+19 -1
View File
@@ -28,6 +28,8 @@ export interface StructuresFormElements {
body: HTMLElement;
groupSelect: HTMLSelectElement;
typeSelect: HTMLSelectElement;
/** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */
ownerNote: HTMLElement;
startFields: StationFields;
anchorFields: StationFields;
endFields: StationFields;
@@ -96,6 +98,13 @@ export function buildStructuresForm(options: {
typeRow.className = "b05-structure__grid";
typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect));
// 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라
// (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자:
// 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다.
const ownerNote = document.createElement("p");
ownerNote.className = "b05-structure__owner-note";
ownerNote.hidden = true;
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
const optionRow = document.createElement("div");
optionRow.className = "b05-structure__grid";
@@ -129,13 +138,22 @@ export function buildStructuresForm(options: {
const positionDivider = document.createElement("hr");
positionDivider.className = "b05-structure__divider";
body.append(typeRow, positionRow, positionDivider, optionRow, facilityOptions.root, actions);
body.append(
typeRow,
ownerNote,
positionRow,
positionDivider,
optionRow,
facilityOptions.root,
actions,
);
return {
root,
body,
groupSelect,
typeSelect,
ownerNote,
startFields,
anchorFields,
endFields,
@@ -60,6 +60,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
body,
groupSelect,
typeSelect,
ownerNote,
startFields,
anchorFields,
endFields,
@@ -208,6 +209,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
startFields.wrap.hidden = !isInterval;
endFields.wrap.hidden = !isInterval;
primary.disabled = !type;
// 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면
// 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자).
ownerNote.textContent = type?.design_owner
? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.`
: "";
ownerNote.hidden = !type?.design_owner;
anchorFields.wrap.querySelector("span")!.textContent = isInterval
? "기준 측점 (비우면 시작)"
: "기준 측점";
@@ -203,6 +203,17 @@
border-top: 1px solid var(--color-border, #3a3f4a);
}
/* 「제원·수량은 다른 화면이 냅니다」 안내 — 경고가 아니라 안내라 색은 흐리게,
* 왼쪽 선 하나로만 구분한다(2026-09-07). */
.b05-structure__owner-note {
margin: 0;
padding: 4px 0 4px 8px;
border-left: 2px solid var(--color-border, #3a3f4a);
color: var(--color-text-muted, #9aa1ad);
font-size: var(--text-caption, 12px);
line-height: 1.5;
}
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
+3
View File
@@ -253,6 +253,8 @@ export async function previewCrossDesigns(
fullDesigns?: boolean;
/** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */
rockBoundaryOffsets?: Record<string, number>;
/** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */
berms?: Record<string, { width_m: number; interval_m: number; slope_deg: number }>;
},
): Promise<CrossDesignPreviewResponse> {
return requestJson<CrossDesignPreviewResponse>(
@@ -264,6 +266,7 @@ export async function previewCrossDesigns(
standard_cross_section: standardCrossSection ?? null,
full_designs: options?.fullDesigns ?? false,
rock_boundary_offsets: options?.rockBoundaryOffsets ?? null,
berms: options?.berms ?? null,
}),
},
);
+13 -1
View File
@@ -28,7 +28,8 @@ import { computeCrossDesign } from "@util/common_util_cross_design";
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
import { readBermSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
import type { BermSpec } from "@util/common_util_cross_berm";
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
import {
effectiveStandardCross,
@@ -162,6 +163,15 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
const rockOffsets = readRockOffsets(projectId, input.routeId);
const rockDefault = readRockBoundaryDefault(projectId);
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
// 순간 계단이 사라진다(계획서 3-9).
const berms = readBermSession(projectId, input.routeId);
const bermAt = (chainageM: number): BermSpec | null => {
const spec = berms[rockKey(chainageM).toFixed(2)];
return spec
? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg }
: null;
};
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
// (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]).
const choices = crossDesignChoices(projectId, input.routeId);
@@ -204,6 +214,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
paved: choice?.paved ?? Boolean(design.paved),
standard,
berm: bermAt(section.chainage_m),
rockBoundaryOffsetM,
twoStageSlope:
choice?.two_stage_slope ??
@@ -243,6 +254,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
{
fullDesigns: true,
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
berms: readBermSession(projectId, routeId),
},
);
if (shouldApply && !shouldApply()) return [];
+105 -47
View File
@@ -27,6 +27,7 @@
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
"""
import math
from collections.abc import Callable
from typing import Any
@@ -34,6 +35,11 @@ from B06_Section.B06_Section_Engine_Areas import (
_split_cut_areas,
_trapezoid_areas,
)
from common_util.common_util_cross_berm import (
BermSpec,
cut_profile_points,
)
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
from config.config_system import (
CURVE_WIDENING_MAX_WIDTH_M,
SECTION_DITCH_SIDES,
@@ -164,6 +170,7 @@ class _SectionGeometry:
ditch_enabled: bool | None = None,
widening_left_m: float = 0.0,
widening_right_m: float = 0.0,
berm: BermSpec | None = None,
) -> None:
half_road = group["road_width_m"] / 2.0
# 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽).
@@ -186,7 +193,9 @@ class _SectionGeometry:
)
self._ground_at = ground_at
self._rock_offset = rock_boundary_offset_m or 0.0
self._rock_knee: dict[str, tuple[float, float] | None] = {}
# 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다.
self.berm = berm
self._cut_points_cache: dict[str, list[tuple[float, float]]] = {}
# 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4).
self._cut_cross: dict[str, float | None] = {}
self._fill_cross: dict[str, float | None] = {}
@@ -275,52 +284,92 @@ class _SectionGeometry:
assert self._ground_at is not None # two_stage일 때만 호출
return self._ground_at(signed) + self._rock_offset
def knee(self, side: str) -> tuple[float, float] | None:
"""절토 사면이 암반 경계선을 지나는 전환점(무릎 거리, 표고)을 구한다(측별 캐시).
def cut_points(self, side: str) -> list[tuple[float, float]]:
"""절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다.
노면 끝(사면 시작)에서 암 경사(cut_ratio)로 올라가며 경계선을 만나면 그 지점부터
토사 경사로 완만해진다. 시작부터 경계 위면 무릎=시작(전부 토사), 끝까지 못 만나면
None(전부 암). 경계선은 지반을 따라 변하므로 세밀 행진으로 교차점을 찾는다.
셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다).
소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다).
"""
if not self.two_stage:
return None
if side in self._rock_knee:
return self._rock_knee[side]
if side in self._cut_points_cache:
return self._cut_points_cache[side]
start_dist, start_z = self._slope_start(side)
diff_prev = start_z - self._rock_boundary_z(side, start_dist)
result: tuple[float, float] | None
if diff_prev >= 0:
result = (start_dist, start_z) # 시작부터 토사(경계 위)
else:
result = None
step = 0.05
dist_prev = start_dist
dist = start_dist + step
while dist <= start_dist + 200.0:
z_rock = start_z + (dist - start_dist) / self.cut_ratio
diff = z_rock - self._rock_boundary_z(side, dist)
if diff >= 0:
span = diff - diff_prev
ratio = (-diff_prev) / span if abs(span) > 1e-9 else 0.0
knee_dist = dist_prev + (dist - dist_prev) * ratio
knee_z = start_z + (knee_dist - start_dist) / self.cut_ratio
result = (knee_dist, knee_z)
break
dist_prev, diff_prev = dist, diff
dist += step
self._rock_knee[side] = result
return result
boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None
points = cut_profile_points(
start_dist,
start_z,
self.cut_ratio,
self.soil_cut_ratio,
boundary,
self.berm,
# 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을
# 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데
# 토사 경사로 그려 절토가 조용히 커진다(2026-09-07).
multi_knee=self.berm is not None,
)
self._cut_points_cache[side] = points
return points
def _cut_slope_z(self, side: str, dist: float) -> float:
"""절토 사면선 표고(2단계 무릎 반영). 지반 교차 클램프는 하지 않는다."""
start_dist, start_z = self._slope_start(side)
knee = self.knee(side) if self.two_stage else None
if knee is not None:
knee_dist, knee_z = knee
if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사
return start_z + (dist - start_dist) / self.cut_ratio
return knee_z + (dist - knee_dist) / self.soil_cut_ratio # 토사 구간: 완만
return start_z + (dist - start_dist) / self.cut_ratio
"""절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.cut_points(side), dist)
def cut_slope_segments(self) -> list[dict[str, Any]]:
"""절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사가 읽는 값이다.
왜 필요한가 — 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져
**위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). 검사는
소단을 뺀 **사면 구간 자체의 경사**를 봐야 하므로 그 구간을 여기서 내보낸다.
· 평탄부(소단)는 싣지 않는다 — 검사 대상이 아니고 경사비가 무한대가 된다.
· 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다.
· `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None.
암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다).
"""
segments: list[dict[str, Any]] = []
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "cut":
continue
cross = self.cut_cross_dist(side)
points = self.cut_points(side)
sign = 1.0 if side == "left" else -1.0
for index in range(1, len(points)):
start_d, start_z = points[index - 1]
end_d, end_z = points[index]
if cross is not None and start_d >= cross - 1e-9:
break # 지반과 만난 뒤는 절토가 없다
if cross is not None and end_d > cross:
# 지반과 만나는 점에서 구간을 자른다.
end_z = berm_elevation_at(points, cross)
end_d = cross
run = end_d - start_d
rise = end_z - start_z
if run <= 1e-9 or rise <= 1e-6:
continue # 길이 0·역방향은 검사 대상이 아니다
if self.berm is not None and abs(run - self.berm.width_m) < 1e-6:
# 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다.
berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m
if abs(rise - berm_rise) < 1e-9:
continue
material: str | None = None
if self.two_stage:
middle_d = (start_d + end_d) / 2.0
middle_z = (start_z + end_z) / 2.0
material = (
"soil" if middle_z >= self._rock_boundary_z(side, middle_d) else "rock"
)
segments.append(
{
"side": side,
"ratio": round(run / rise, 4),
"rise_m": round(rise, 4),
"run_m": round(run, 4),
"start_offset_m": round(sign * start_d, 4),
"end_offset_m": round(sign * end_d, 4),
"material": material,
}
)
return segments
def cut_cross_dist(self, side: str) -> float | None:
"""절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4).
@@ -439,15 +488,20 @@ class _SectionGeometry:
return max(fill_line, ground_m)
def breakpoints(self) -> list[float]:
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎 포함)."""
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함)."""
points = [0.0, self.left_extent, -self.right_extent]
points.extend(offset for offset, _z in self.ditch_points)
if self.two_stage:
# 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
if self.two_stage or self.berm is not None:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
knee = self.knee(side) if role == "cut" else None
if knee is not None:
points.append(knee[0] if side == "left" else -knee[0])
if role != "cut":
continue
cross = self.cut_cross_dist(side)
for offset, _z in self.cut_points(side):
if cross is not None and offset > cross + 1e-9:
break # 지반과 만난 뒤는 절토가 없다
points.append(offset if side == "left" else -offset)
# 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4).
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
@@ -490,6 +544,7 @@ def compute_cross_design(
plan_radius_m: float | None = None,
curve_outer_side: str | None = None,
curve_widening_m: float | None = None,
berm: BermSpec | None = None,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
@@ -577,6 +632,7 @@ def compute_cross_design(
ditch_enabled=ditch_enabled,
widening_left_m=widening_left,
widening_right_m=widening_right,
berm=berm,
)
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
@@ -718,6 +774,8 @@ def compute_cross_design(
),
"ditch_area_m2": round(ditch_area, 4),
"design_line": design_line,
# 절토 사면을 경사 구간별로 쪼갠 목록 — 법정 경사 검사가 읽는다(소단 제외).
"cut_slope_segments": geometry.cut_slope_segments(),
}
if drop > 0:
# 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다.
+1
View File
@@ -553,6 +553,7 @@ async def preview_cross_designs(
request.standard_cross_section,
request.rock_boundary_offsets,
project_root,
request.berms,
)
await asyncio.to_thread(rebuild)
+19
View File
@@ -10,6 +10,12 @@ from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from common_util.common_util_cross_berm import (
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
BermSpec,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
@@ -343,6 +349,7 @@ def recompute_designs_for_alignment(
standard: dict[str, Any] | None,
rock_boundary_offsets: dict[str, float] | None = None,
project_root: Path | None = None,
berms: dict[str, dict[str, float]] | None = None,
) -> None:
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
@@ -358,6 +365,17 @@ def recompute_designs_for_alignment(
session_offsets[round(float(raw_key), 3)] = float(offset)
except (TypeError, ValueError):
continue
# 측점별 소단 제원 — 값이 없는 측점은 소단 없음(종전 설계 그대로).
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)),
)
except (TypeError, ValueError, AttributeError):
continue
for section in cross_sections:
chainage = float(section.get("chainage_m", 0.0))
key = round(chainage, 3)
@@ -380,6 +398,7 @@ def recompute_designs_for_alignment(
two_stage_slope=bool(stored.get("two_stage_slope", True)),
ditch_enabled=stored.get("ditch_enabled"),
surface_drop_m=ford_drop_at(chainage, ford_drops),
berm=session_berms.get(key),
**curve_widening_args(section),
)
except (ValueError, KeyError):
+1 -3
View File
@@ -64,9 +64,7 @@ async def compute_haul_plan(
{"haul_plan_for": result, "context": _mass_haul_context()},
)
except Exception:
logger.exception(
"유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id
)
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
+4
View File
@@ -298,6 +298,10 @@ class CrossDesignPreviewRequest(BaseModel):
# 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는
# 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다.
rock_boundary_offsets: dict[str, float] | None = None
# 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg}). 위와 같은 성격으로,
# 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9).
# 값이 없는 측점은 소단 없음 — 종전 설계 그대로다.
berms: dict[str, dict[str, float]] | None = None
def edits(self) -> dict[str, Any]:
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
@@ -52,6 +52,27 @@ export function readRockBoundarySession(
return stored && typeof stored === "object" ? stored : {};
}
/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */
export interface BermSessionSpec {
width_m: number;
interval_m: number;
slope_deg: number;
}
/**
* ( ). .
*
* **
* ** . ( 3-9).
*/
export function readBermSession(
projectId: string,
routeId: number,
): Record<string, BermSessionSpec> {
const stored = readState<Record<string, BermSessionSpec>>("berm", projectId, routeId);
return stored && typeof stored === "object" ? stored : {};
}
/** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */
export interface RockBoundaryStore {
/** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */
+166
View File
@@ -0,0 +1,166 @@
"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9).
: `common_util/common_util_cross_berm.ts`. 파일은 같은 값을 내야 하며
`tmp/tests/test_cross_berm_mirror.py` 그것을 지킨다.
** 따로 뺐나** 소단이 들어가면 절토선이 하나의 경사 아니라 **계단** 된다.
지금 코드는 경계 무릎을 **하나만** 전제하는데(경계를 지나면 ), 사용자가 소단을
겹쳐 놓을 있으므로 경계를 **여러 ** 오갈 있다. 그래서 무릎을 미리 구하는 대신
**바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은
값이 나온다(거울 시험이 그것도 지킨다).
**소단 기본값** 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°.
· ·간격은 별표2 범위(사면길이 2~3m마다 · 50~100) 안에서 **가장 적게 파는 조합**이다.
기본값은 되돌리기 쉬운 쪽이어야 한다 넣는 것은 폼에서 번이지만 이미 것을
되돌리면 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준):
0.5·간격 3 1:1.24 / 1.0·간격 3 1:1.47 / 1.0·간격 2 **1:1.71**.
넓고 촘촘하면 설계 1:1 실제로는 1:1.7 서서 다른 비탈이 된다.
· 기울기 2° **2026-09-07 사용자 확정**이다 물이 고이지 않게 안쪽으로 기울이는 실무이고
**법령·교본 근거가 없다**. 그래서 지식DB 에는 적지 않는다(사용자 지시).
"""
import math
from typing import Callable, NamedTuple
# 소단 기본값 — 근거는 위 모듈 설명.
BERM_DEFAULT_WIDTH_M = 0.5
BERM_DEFAULT_INTERVAL_M = 3.0
BERM_DEFAULT_SLOPE_DEG = 2.0
# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다.
_STEP_M = 0.05
_MAX_REACH_M = 200.0
class BermSpec(NamedTuple):
"""소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도)."""
width_m: float = BERM_DEFAULT_WIDTH_M
interval_m: float = BERM_DEFAULT_INTERVAL_M
slope_deg: float = BERM_DEFAULT_SLOPE_DEG
def cut_profile_points(
start_dist: float,
start_z: float,
cut_ratio: float,
soil_cut_ratio: float,
rock_boundary_z: Callable[[float], float] | None,
berm: BermSpec | None,
max_reach_m: float = _MAX_REACH_M,
multi_knee: bool = False,
) -> list[tuple[float, float]]:
"""절토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로.
`rock_boundary_z` None 이면 2단계 절토가 아니므로 경사는 `cut_ratio` 하나다.
있으면 걸어가며 경계를 만나는 자리에서 (`cut_ratio`) 토사(`soil_cut_ratio`) ** **
꺾는다.
`multi_knee` 거짓이면 ** 번만** 꺾는다(기존 규칙 그대로). 참이면 경계를 오갈 때마다
꺾는다.
**소단이 있으면 반드시 참이어야 한다.** 소단은 평탄한데 경계선은 지반을 따라 올라가므로,
0.5m 짜리 소단 하나만 지나도 설계선이 경계 **아래로 되돌아가는 일이 흔하다**(지반이
1:1 이면 경계는 0.5m 오르고 소단은 2°=0.017m 오른다). 번만 꺾으면 구간을 **암인데
토사 경사로** 그려 절토가 조용히 커진다 경고도 뜬다(2026-09-07 배분 지적으로 확인).
반대로 소단이 없을 참으로 두면 **지금 측점들의 설계가 같이 바뀐다**(실측: 물결 경계에서
0.0016m). 그래서 기본값은 거짓이고, 소단을 때만 참으로 켠다.
`berm` 있으면 사면길이가 `interval_m` 닿을 때마다 `width_m` 평탄부를 넣는다.
평탄부는 안쪽이 낮도록 `slope_deg` 만큼 기울어 있어 바깥으로 갈수록 조금 올라간다
(물이 노면 쪽으로 흐르게 소단측구를 놓는 자리다).
꼭짓점만 돌려준다 경사가 바뀌는 점과 소단 모서리뿐이라 사이는 직선이다.
"""
points: list[tuple[float, float]] = [(start_dist, start_z)]
dist, elevation = start_dist, start_z
slant_since_berm = 0.0
limit = start_dist + max_reach_m
berm_rise = (
math.tan(math.radians(berm.slope_deg)) * berm.width_m
if berm is not None and berm.width_m > 0
else 0.0
)
# 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다).
in_soil = rock_boundary_z is None or elevation >= rock_boundary_z(dist)
ratio = soil_cut_ratio if (rock_boundary_z is not None and in_soil) else cut_ratio
while dist < limit:
rise = _STEP_M / ratio
slant = math.hypot(_STEP_M, rise)
# ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다.
if berm is not None and berm.interval_m > 0 and slant_since_berm + slant >= berm.interval_m:
remain = max(berm.interval_m - slant_since_berm, 0.0)
run = remain / math.hypot(1.0, 1.0 / ratio)
dist += run
elevation += run / ratio
points.append((dist, elevation)) # 소단 안쪽 모서리
dist += berm.width_m
elevation += berm_rise
points.append((dist, elevation)) # 소단 바깥 모서리
slant_since_berm = 0.0
continue
next_dist = dist + _STEP_M
next_z = elevation + rise
# ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다.
# `multi_knee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙).
if rock_boundary_z is not None and (multi_knee or not in_soil):
diff_now = elevation - rock_boundary_z(dist)
diff_next = next_z - rock_boundary_z(next_dist)
crossed = (diff_next >= 0) if not in_soil else (diff_next < 0)
if crossed:
span = diff_next - diff_now
share = (-diff_now) / span if abs(span) > 1e-12 else 0.0
share = min(max(share, 0.0), 1.0)
knee_dist = dist + _STEP_M * share
knee_z = elevation + rise * share
slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation)
dist, elevation = knee_dist, knee_z
points.append((dist, elevation)) # 무릎
in_soil = not in_soil
ratio = soil_cut_ratio if in_soil else cut_ratio
continue
dist, elevation = next_dist, next_z
slant_since_berm += slant
points.append((dist, elevation))
return _dedupe(points)
def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게."""
out: list[tuple[float, float]] = []
for point in points:
if out and abs(point[0] - out[-1][0]) < 1e-9 and abs(point[1] - out[-1][1]) < 1e-9:
continue
out.append(point)
return out
def elevation_at(points: list[tuple[float, float]], dist: float) -> float:
"""꼭짓점 목록에서 거리 하나의 표고 — 사이는 직선 보간, 끝은 마지막 경사 연장."""
if not points:
return 0.0
if dist <= points[0][0]:
return points[0][1]
for index in range(1, len(points)):
x0, z0 = points[index - 1]
x1, z1 = points[index]
if dist > x1 + 1e-12:
continue
span = x1 - x0
if span <= 1e-12:
return z1
return z0 + (z1 - z0) * ((dist - x0) / span)
# 끝을 넘어가면 마지막 두 점의 기울기로 잇는다.
x0, z0 = points[-2] if len(points) > 1 else points[-1]
x1, z1 = points[-1]
span = x1 - x0
if span <= 1e-12:
return z1
return z1 + (z1 - z0) / span * (dist - x1)
+155
View File
@@ -0,0 +1,155 @@
/* =============================================================================
* common_util/common_util_cross_berm.ts
* **() ** ( 3-9)
*
* : `common_util/common_util_cross_berm.py` .
* : `tmp/tests/test_cross_berm_mirror.py`
*
* **** .
* ··3D .
*
* 0.5m · () 3.0m · 2°.
* · · 2 ( 2~3m마다 · 50~100) ** **.
*
* . ( 1:1 ):
* 0.5· 3 1:1.24 / 1.0· 3 1:1.47 / 1.0· 2 **1:1.71**.
* · 2° **2026-09-07 ** · DB .
* ========================================================================== */
/** 소단 기본값 — 근거는 위 설명. */
export const BERM_DEFAULT_WIDTH_M = 0.5;
export const BERM_DEFAULT_INTERVAL_M = 3.0;
export const BERM_DEFAULT_SLOPE_DEG = 2.0;
/** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */
const STEP_M = 0.05;
const MAX_REACH_M = 200.0;
/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */
export interface BermSpec {
widthM: number;
intervalM: number;
slopeDeg: number;
}
export function bermSpec(
widthM = BERM_DEFAULT_WIDTH_M,
intervalM = BERM_DEFAULT_INTERVAL_M,
slopeDeg = BERM_DEFAULT_SLOPE_DEG,
): BermSpec {
return { widthM, intervalM, slopeDeg };
}
/**
* : `cut_profile_points`. `[[거리, 표고], ...]` .
*
* `rockBoundaryZ` null 2 .
* ** ** .
*
* ** **.
* (2026-09-07).
*/
export function cutProfilePoints(
startDist: number,
startZ: number,
cutRatio: number,
soilCutRatio: number,
rockBoundaryZ: ((dist: number) => number) | null,
berm: BermSpec | null,
maxReachM: number = MAX_REACH_M,
multiKnee = false,
): Array<[number, number]> {
const points: Array<[number, number]> = [[startDist, startZ]];
let dist = startDist;
let elevation = startZ;
let slantSinceBerm = 0;
const limit = startDist + maxReachM;
const bermRise =
berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0;
// 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다).
let inSoil = rockBoundaryZ === null || elevation >= rockBoundaryZ(dist);
let ratio = rockBoundaryZ !== null && inSoil ? soilCutRatio : cutRatio;
while (dist < limit) {
const rise = STEP_M / ratio;
const slant = Math.hypot(STEP_M, rise);
// ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다.
if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) {
const remain = Math.max(berm.intervalM - slantSinceBerm, 0);
const run = remain / Math.hypot(1, 1 / ratio);
dist += run;
elevation += run / ratio;
points.push([dist, elevation]); // 소단 안쪽 모서리
dist += berm.widthM;
elevation += bermRise;
points.push([dist, elevation]); // 소단 바깥 모서리
slantSinceBerm = 0;
continue;
}
const nextDist = dist + STEP_M;
const nextZ = elevation + rise;
// ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다.
// `multiKnee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙).
if (rockBoundaryZ !== null && (multiKnee || !inSoil)) {
const diffNow = elevation - rockBoundaryZ(dist);
const diffNext = nextZ - rockBoundaryZ(nextDist);
const crossed = !inSoil ? diffNext >= 0 : diffNext < 0;
if (crossed) {
const span = diffNext - diffNow;
let share = Math.abs(span) > 1e-12 ? -diffNow / span : 0;
share = Math.min(Math.max(share, 0), 1);
const kneeDist = dist + STEP_M * share;
const kneeZ = elevation + rise * share;
slantSinceBerm += Math.hypot(kneeDist - dist, kneeZ - elevation);
dist = kneeDist;
elevation = kneeZ;
points.push([dist, elevation]); // 무릎
inSoil = !inSoil;
ratio = inSoil ? soilCutRatio : cutRatio;
continue;
}
}
dist = nextDist;
elevation = nextZ;
slantSinceBerm += slant;
}
points.push([dist, elevation]);
return dedupe(points);
}
/** 같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게. */
function dedupe(points: Array<[number, number]>): Array<[number, number]> {
const out: Array<[number, number]> = [];
for (const point of points) {
const last = out[out.length - 1];
if (last && Math.abs(point[0] - last[0]) < 1e-9 && Math.abs(point[1] - last[1]) < 1e-9) {
continue;
}
out.push(point);
}
return out;
}
/** 짝: `elevation_at`. 꼭짓점 목록에서 거리 하나의 표고(사이는 직선, 끝은 연장). */
export function elevationAt(points: Array<[number, number]>, dist: number): number {
if (points.length === 0) return 0;
if (dist <= points[0][0]) return points[0][1];
for (let index = 1; index < points.length; index += 1) {
const [x0, z0] = points[index - 1];
const [x1, z1] = points[index];
if (dist > x1 + 1e-12) continue;
const span = x1 - x0;
if (span <= 1e-12) return z1;
return z0 + (z1 - z0) * ((dist - x0) / span);
}
const [x0, z0] = points.length > 1 ? points[points.length - 2] : points[points.length - 1];
const [x1, z1] = points[points.length - 1];
const span = x1 - x0;
if (span <= 1e-12) return z1;
return z1 + ((z1 - z0) / span) * (dist - x1);
}
+8
View File
@@ -23,10 +23,12 @@
* 3. .
* ========================================================================== */
import type { BermSpec } from "./common_util_cross_berm";
import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
// 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04).
import {
CURVE_WIDENING_MAX_WIDTH_M,
type CutSlopeSegment,
SectionGeometry,
curveWideningM,
type ResolvedGroup,
@@ -88,6 +90,8 @@ export interface CrossDesignOptions {
curveOuterSide?: "left" | "right" | null;
/** 저장된 확폭량(m) — 곡선 앞뒤 테이퍼가 얹힌 값. 있으면 반경 표값 대신 쓴다. */
curveWideningM?: number | null;
/** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
berm?: BermSpec | null;
}
export interface CrossDesignEdge {
@@ -128,6 +132,8 @@ export interface CrossDesignResult {
fill_ground_slope: number | null;
ditch_area_m2: number;
design_line: CrossDesignEdge[];
/** 절토 사면 경사 구간(소단 제외) — 법정 경사 검사가 읽는다. 짝: `cut_slope_segments`. */
cut_slope_segments: CutSlopeSegment[];
surface_drop_m?: number;
pavement_thickness_m?: number;
rock_boundary_offset_m?: number;
@@ -298,6 +304,7 @@ export function computeCrossDesign(
rockBoundaryOffsetM,
twoStageSlope: enableTwoStage,
ditchEnabled: options.ditchEnabled ?? null,
berm: options.berm ?? null,
});
// 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만).
@@ -430,6 +437,7 @@ export function computeCrossDesign(
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
ditch_area_m2: round4(ditchArea),
design_line: designLine,
cut_slope_segments: geometry.cutSlopeSegments(),
};
if (drop > 0) result.surface_drop_m = round4(drop);
if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m);
+100 -44
View File
@@ -6,9 +6,26 @@
* `common_util_cross_design.ts` 700 (2026-09-04) .
* ========================================================================== */
import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm";
/** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */
export interface CutSlopeSegment {
side: string;
ratio: number;
rise_m: number;
run_m: number;
start_offset_m: number;
end_offset_m: number;
material: string | null;
}
/** 파이썬 `round(x, 4)` 와 같은 자리 맞춤. */
function round4(value: number): number {
return Math.round(value * 10000) / 10000;
}
/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */
const MARCH_STEP_M = 0.05;
const KNEE_MAX_M = 200;
const CROSS_MAX_M = 500;
export interface ResolvedGroup {
@@ -87,7 +104,9 @@ export class SectionGeometry {
ditchPoints: Array<[number, number]> = [];
private groundAt: ((offsetM: number) => number) | null;
private rockOffset: number;
private rockKnee = new Map<string, [number, number] | null>();
/** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */
berm: BermSpec | null = null;
private cutPointsCache = new Map<string, Array<[number, number]>>();
private cutCross = new Map<string, number | null>();
private fillCross = new Map<string, number | null>();
@@ -106,6 +125,8 @@ export class SectionGeometry {
/** 곡선부 확폭(m) — 붙는 쪽만 값이 있고 반대쪽은 0이다. */
wideningLeftM?: number;
wideningRightM?: number;
/** 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
berm?: BermSpec | null;
}) {
const { group } = params;
const halfRoad = group.road_width_m / 2;
@@ -124,6 +145,7 @@ export class SectionGeometry {
params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null,
);
this.groundAt = params.groundAt;
this.berm = params.berm ?? null;
this.rockOffset = params.rockBoundaryOffsetM ?? 0;
this.ditchType = params.ditchType;
// 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약).
@@ -203,50 +225,30 @@ export class SectionGeometry {
return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset;
}
/** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */
knee(side: string): [number, number] | null {
if (!this.twoStage) return null;
const cached = this.rockKnee.get(side);
/** 짝: `cut_points`. 절토 사면 꼭짓점 — 무릎과 소단이 모두 여기 들어 있다. */
cutPoints(side: string): Array<[number, number]> {
const cached = this.cutPointsCache.get(side);
if (cached !== undefined) return cached;
const [startDist, startZ] = this.slopeStart(side);
let diffPrev = startZ - this.rockBoundaryZ(side, startDist);
let result: [number, number] | null;
if (diffPrev >= 0) {
result = [startDist, startZ]; // 시작부터 토사(경계 위)
} else {
result = null;
let distPrev = startDist;
let dist = startDist + MARCH_STEP_M;
while (dist <= startDist + KNEE_MAX_M) {
const zRock = startZ + (dist - startDist) / this.cutRatio;
const diff = zRock - this.rockBoundaryZ(side, dist);
if (diff >= 0) {
const span = diff - diffPrev;
const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0;
const kneeDist = distPrev + (dist - distPrev) * ratio;
const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio;
result = [kneeDist, kneeZ];
break;
}
distPrev = dist;
diffPrev = diff;
dist += MARCH_STEP_M;
}
}
this.rockKnee.set(side, result);
return result;
const boundary = this.twoStage ? (dist: number) => this.rockBoundaryZ(side, dist) : null;
const points = cutProfilePoints(
startDist,
startZ,
this.cutRatio,
this.soilCutRatio,
boundary,
this.berm,
undefined,
// 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07).
this.berm !== null,
);
this.cutPointsCache.set(side, points);
return points;
}
/** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */
/** 짝: `_cut_slope_z`. 절토 사면선 표고(무릎·소단 반영, 지반 클램프 없음). */
private cutSlopeZ(side: string, dist: number): number {
const [startDist, startZ] = this.slopeStart(side);
const knee = this.twoStage ? this.knee(side) : null;
if (knee !== null) {
const [kneeDist, kneeZ] = knee;
if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio;
return kneeZ + (dist - kneeDist) / this.soilCutRatio;
}
return startZ + (dist - startDist) / this.cutRatio;
return elevationAt(this.cutPoints(side), dist);
}
/** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */
@@ -350,15 +352,69 @@ export class SectionGeometry {
return Math.max(startZ - run / this.fillRatio, groundM);
}
/**
* : `cut_slope_segments`. ** ** .
*
*
* . ** ** .
*/
cutSlopeSegments(): CutSlopeSegment[] {
const segments: CutSlopeSegment[] = [];
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
if (role !== "cut") continue;
const cross = this.cutCrossDist(side);
const points = this.cutPoints(side);
const sign = side === "left" ? 1 : -1;
for (let index = 1; index < points.length; index += 1) {
const [startD, startZ] = points[index - 1];
let [endD, endZ] = points[index];
if (cross !== null && startD >= cross - 1e-9) break; // 지반과 만난 뒤는 절토가 없다
if (cross !== null && endD > cross) {
endZ = elevationAt(points, cross);
endD = cross;
}
const run = endD - startD;
const rise = endZ - startZ;
if (run <= 1e-9 || rise <= 1e-6) continue;
if (this.berm !== null && Math.abs(run - this.berm.widthM) < 1e-6) {
const bermRise = Math.tan((this.berm.slopeDeg * Math.PI) / 180) * this.berm.widthM;
if (Math.abs(rise - bermRise) < 1e-9) continue; // 소단(평탄부)
}
let material: string | null = null;
if (this.twoStage) {
const middleD = (startD + endD) / 2;
const middleZ = (startZ + endZ) / 2;
material = middleZ >= this.rockBoundaryZ(side, middleD) ? "soil" : "rock";
}
segments.push({
side,
ratio: round4(run / rise),
rise_m: round4(rise),
run_m: round4(run),
start_offset_m: round4(sign * startD),
end_offset_m: round4(sign * endD),
material,
});
}
}
return segments;
}
/** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */
breakpoints(): number[] {
const points = [0, this.leftExtent, -this.rightExtent];
for (const [offset] of this.ditchPoints) points.push(offset);
if (this.twoStage) {
// 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
if (this.twoStage || this.berm !== null) {
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
const knee = role === "cut" ? this.knee(side) : null;
if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]);
if (role !== "cut") continue;
const cross = this.cutCrossDist(side);
for (const [offset] of this.cutPoints(side)) {
if (cross !== null && offset > cross + 1e-9) break; // 지반과 만난 뒤는 절토가 없다
points.push(side === "left" ? offset : -offset);
}
}
}
for (const side of ["left", "right"]) {
+52 -2
View File
@@ -160,6 +160,38 @@ def fill_pipe_coordinates(points: list[PipePoint], vertices: list[RouteVertex])
return points
# 같은 노선으로 볼 누가거리 어긋남의 한계(m).
#
# 지문(`route_signature`)은 좌표를 **0.01m 자리에서 끊어** 해시한다. 그런데 같은 노선이
# `planned_route.csv`(소수 4자리)와 `route_main.geojson`(소수 3자리, csv 를 mm 로 반올림한
# 사본)로 **0.5mm 다르게** 저장돼 있어, 그 0.5mm 가 `.xx5` 경계를 넘는 정점마다 글자가
# 바뀐다(2026-09-07 실측: 169개 중 **16개**). 노선을 손댄 적이 없는데도 지문이 늘 달랐다.
#
# 경계에서 자르는 방식은 저장 자릿수가 또 바뀌면 다시 흔들리므로 **글자 일치 대신
# 허용오차**로 가른다. 값은 0.05m — 위 어긋남이 관 누가거리에 미치는 양이 실측
# **최대 0.01m** 이라 다섯 배 여유를 두었고, 사람이 노선을 실제로 고치면 관은 **m 단위**로
# 밀리므로 그것을 「같다」로 볼 위험은 없다.
ROUTE_MATCH_TOLERANCE_M = 0.05
def max_projection_shift(points: list[PipePoint], vertices: list[RouteVertex]) -> float | None:
"""저장된 관을 이 노선에 투영하면 누가거리가 최대 얼마나 움직이나 (**고치지 않고 잰다**).
좌표가 없는 관이 하나라도 있으면 없어 None.
"""
if not vertices or not points:
return None
if any(point.x is None or point.y is None for point in points):
return None
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
if line.length <= 0:
return None
return max(
abs(float(line.project(Point(point.x, point.y))) - float(point.chainage_m))
for point in points
)
def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]:
"""저장된 좌표를 주어진 노선에 투영해 누가거리를 다시 매긴다.
@@ -214,10 +246,28 @@ def load_pipe_points_file(
stored_signature = str(document.get("route_signature") or "")
if stored_signature == signature:
return points
if vertices and points and all(p.x is not None and p.y is not None for p in points):
# 지문이 다르다고 노선이 바뀐 것은 아니다 — 같은 노선을 두 파일이 0.5mm 다르게 담고
# 있어 글자가 늘 어긋난다(위 `ROUTE_MATCH_TOLERANCE_M` 주석). 관이 실제로 얼마나
# 밀리는지 **재 보고** 한계 안이면 저장분을 그대로 쓴다 — 건드리지 않는 것이 정답이다.
shift = max_projection_shift(points, vertices) if vertices else None
if shift is not None and shift <= ROUTE_MATCH_TOLERANCE_M:
logger.info(
"배수유역: 노선이 바뀌어 관 지점 %d건을 좌표로 이월합니다 (%s).",
"배수유역: 지문은 다르나 같은 노선입니다 — 관 %d건 그대로 씁니다 "
"(최대 어긋남 %.4fm ≤ %.2fm, %s).",
len(points),
shift,
ROUTE_MATCH_TOLERANCE_M,
path.name,
)
return points
if vertices and points and all(p.x is not None and p.y is not None for p in points):
# ⚠ 이 줄이 찍히면 **투영 이월이 실제로 돈 것**이다. 한동안 0 인 것을 확인한 뒤에야
# 이 가지를 지울 수 있다(계획서 0-7 — 먼저 지우면 관이 통째로 사라진다).
logger.warning(
"배수유역: 투영 이월 실행 — 노선이 바뀌어 관 지점 %d건을 좌표로 옮깁니다 "
"(최대 어긋남 %s, %s).",
len(points),
f"{shift:.3f}m" if shift is not None else "잴 수 없음",
path.name,
)
return project_pipe_points(points, vertices)
@@ -0,0 +1,92 @@
"""구조물 정본에서 **시설별 연장(m)** 을 낸다 — B군 배수시설 수량의 입력 (계획서 3-6).
연장만인가 B군(측구·산마루측구·도수로·맹암거 ) 수량 단위가 **전부 m** 이다
(맹암거 12-10 · L형 측구 12-9-1 · 산마루측구 12-9-2 · 소단측구 12-9-3, 지식DB
`04_수량분석정보/배수공_수량.md`). 그래서 단면 기하 없이 구간 길이만으로 셈이 된다.
별표2의 횡단면도 측점 기입 물량 목록에도 B군은 없어 도면에 그릴 의무가 없다
(2026-09-07 조사).
**수량서 양식에 묶인다** 여기서는 종류별 연장·개소만 내고, 어느 코드·어느 칸에
넣을지는 B08 정한다. B08 실무 xlsx 양식으로 재작업 예정이라 사이에 두는 것이다.
"""
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
# 아직 셈하지 않는 타입 — 계획서 3-9(소단 만들기)가 끝난 뒤에 채운다. 지금 설계에는
# 소단(berm)이 없어 이 시설이 설 자리 자체가 없다(2026-09-07 조사·사용자 확정).
PENDING_TYPE_IDS = frozenset({"ditch_berm"})
def _merge(spans: list[tuple[float, float]]) -> float:
"""겹치는 구간을 합쳐 실제 덮인 길이를 낸다.
같은 시설을 겹치게 넣으면 단순 합은 구간을 ** 센다**. 연장은 덮인
길이 겹침을 지우는 쪽이 맞다. 원래 (`raw_length_m`) 함께 내보내므로 입력이
겹쳤다는 사실은 숨지 않는다.
"""
total = 0.0
current_start: float | None = None
current_end = 0.0
for start, end in sorted(spans):
if current_start is None:
current_start, current_end = start, end
continue
if start <= current_end:
current_end = max(current_end, end)
continue
total += current_end - current_start
current_start, current_end = start, end
if current_start is not None:
total += current_end - current_start
return total
def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]:
"""구간형 구조물의 시설별 연장을 돌려준다 (기준점 순, 종류별 한 줄).
빼는
· `managed_by` 타입(배관 ): 구조물 정본이 아니라 지점 정본 소관이다.
· `design_owner` 타입(측구 = 횡단 설계): 횡단이 이미 터파기 단면적까지 셈하므로
여기서 세면 **같은 것을 계상**한다(2026-09-07 사용자 확정).
· `PENDING_TYPE_IDS`(소단측구): 놓일 소단이 아직 없다.
시작·종료는 있다 구간형은 스키마가 없으면 저장을 막는다
(`StructureInstance.validate_placement_fields`).
"""
types = structure_type_map()
spans: dict[str, list[tuple[float, float]]] = {}
counts: dict[str, int] = {}
for structure in load_structures(str(project_root))[1]:
definition = types.get(structure.type_id)
if definition is None or definition.placement != "interval":
continue
if definition.managed_by or definition.design_owner:
continue
if structure.type_id in PENDING_TYPE_IDS:
continue
start, end = float(structure.start_m), float(structure.end_m)
counts[structure.type_id] = counts.get(structure.type_id, 0) + 1
spans.setdefault(structure.type_id, []).append((min(start, end), max(start, end)))
rows: list[dict[str, Any]] = []
for type_id, count in counts.items():
entries = spans[type_id]
rows.append(
{
"type_id": type_id,
"group": types[type_id].group,
"name": types[type_id].name,
"count": count,
# 겹침을 지운 실제 연장 — 수량서에 쓸 값.
"length_m": round(_merge(entries), 2),
# 입력한 구간 길이의 단순 합 — 위와 다르면 구간이 겹쳐 있다는 뜻.
"raw_length_m": round(sum(end - start for start, end in entries), 2),
}
)
rows.sort(key=lambda row: (row["group"], row["name"]))
return rows