Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into CODEX

This commit is contained in:
2026-10-09 14:50:53 +09:00
40 changed files with 763 additions and 134 deletions
+9 -1
View File
@@ -108,6 +108,14 @@ const PRESERVED_KEYS = [
...SPOIL_KEYS,
] as const;
/**
* 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다.
* 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다.
*/
export function pavedBySpecs(section: Pick<CrossSection, "pavement" | "ford_pavement">): boolean {
return Boolean(section.pavement?.length) || Boolean(section.ford_pavement);
}
function preserveUserFields(
next: NonNullable<CrossSection["design"]>,
previous: CrossSection["design"],
@@ -293,7 +301,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
ditchType:
choice?.ditch_type ??
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
paved: choice?.paved ?? Boolean(design.paved),
paved: pavedBySpecs(section),
standard,
berm: bermAt(section.chainage_m),
rockBoundaryOffsetM,
+25 -18
View File
@@ -226,26 +226,29 @@ def _side_spec(
return spec
def _chute(values: dict[str, Any]) -> dict[str, Any] | None:
"""도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) ·
구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림. TS 짝 `chute`."""
head = re.compile(r"^chute_add_(\d+)_structure$")
def _chute(values: dict[str, Any], head: str = "chute_add") -> dict[str, Any] | None:
"""도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 ·
두께(cm → m · 빈 값 30 cm) · 구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림.
TS 짝 `chute`."""
pattern = re.compile(rf"^{head}_(\d+)_structure$")
picked = sorted(
(int(hit.group(1)), str(value).strip())
for key, value in values.items()
if (hit := head.match(key)) and str(value or "").strip()
if (hit := pattern.match(key)) and str(value or "").strip()
)
if not picked:
return None
n, structure = picked[0]
cm = _number(values.get(f"chute_add_{n}_thickness"), None)
cm = _number(values.get(f"{head}_{n}_thickness"), None)
cm = cm if cm and cm > 0 else CHUTE_THICKNESS_CM
return {"structure": structure, "thickness_m": round(cm / 100.0, 3)}
def _with_chute(spec: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
def _with_chute(
spec: dict[str, Any], values: dict[str, Any], head: str = "chute_add"
) -> dict[str, Any]:
"""도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2)."""
chute = _chute(values)
chute = _chute(values, head)
if chute:
spec["chute"] = chute
return spec
@@ -502,16 +505,20 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
used = degree if degree is not None else fallback
slope = round(math.tan(math.radians(used)) * 100.0, 4) if used is not None else legacy
span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M
return {
"type": "ford_pavement",
"span_m": span,
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
"depth_m": depth if depth and depth > 0 else None,
"slope_pct": slope,
"defaulted": _defaulted(
values, [("ford_width_m", "월류 폭", span), ("ford_slope_deg", "경사각", fallback)]
),
}
return _with_chute(
{
"type": "ford_pavement",
"span_m": span,
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
"depth_m": depth if depth and depth > 0 else None,
"slope_pct": slope,
"defaulted": _defaulted(
values, [("ford_width_m", "월류 폭", span), ("ford_slope_deg", "경사각", fallback)]
),
},
values,
"ford_chute",
)
def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
+3 -1
View File
@@ -44,6 +44,8 @@ from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
ford_drop_at,
ford_surface_drops,
paved_at,
pavement_ranges,
stored_berm,
stored_cut_slope,
)
@@ -640,7 +642,7 @@ async def compute_cross_section_design(
section_mode=request.section_mode,
ditch_side=request.ditch_side,
ditch_type=request.ditch_type,
paved=request.paved,
paved=paved_at(request.chainage_m, pavement_ranges(project_root)),
standard=request.standard_cross_section,
rock_boundary_offset_m=request.rock_boundary_offset_m,
two_stage_slope=request.two_stage_slope,
+19 -15
View File
@@ -110,12 +110,17 @@ def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float:
return 0.0
def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool:
"""이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장)."""
for start, end in ranges:
if start - 1e-9 <= chainage_m <= end + 1e-9:
return True
return bool(stored) if isinstance(stored, bool) else False
# 범위 끝 허용오차(m) — 브라우저 짝(`common_util_structure_pavement` · `_culvert_sets`)과 같은 값.
_PAVED_EDGE_TOLERANCE_M = 0.02
def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool:
"""이 측점을 포장으로 볼 것인가 — 노면공 · 물넘이 범위 안만 포장, 밖은 비포장(저장값 무시).
브라우저 짝 = `B06_Section_Cross_Refresh.ts` `pavedBySpecs`(같은 규칙 · 138-1).
"""
tol = _PAVED_EDGE_TOLERANCE_M
return any(start - tol <= chainage_m <= end + tol for start, end in ranges)
# 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.**
@@ -152,7 +157,6 @@ CARRY_INPUT_KEYS = (
"ground_type",
"ditch_type",
"ditch_choice",
"paved",
"two_stage_slope",
"rock_boundary_offset_m",
"cut_slope_ratio_user",
@@ -178,23 +182,23 @@ def enforce_pavement_ranges(
project_root: Path,
standard: dict[str, Any] | None = None,
) -> int:
"""포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다.
"""포장 = 노면공 · 물넘이 범위 안만 — 저장분과 다르면 켬 · 끔 양쪽으로 맞춘다(138-1).
사용자가 구간을 지정하면 그 안 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정).
범위 밖 옛 `paved=true`(지운 노면공 · 옛 카드 단추)는 비포장으로 되돌린다.
포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다.
사용자 조작값(반폭·구조물 조정)은 그대로 승계한다.
"""
ranges = pavement_ranges(project_root)
if not ranges:
return 0
ford_drops = ford_surface_drops(project_root)
changed = 0
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict) or design.get("paved"):
if not isinstance(design, dict):
continue
chainage = float(section.get("chainage_m", 0.0))
if not paved_at(chainage, ranges):
wanted = paved_at(chainage, ranges)
if bool(design.get("paved")) == wanted:
continue
try:
recomputed = compute_cross_design(
@@ -204,7 +208,7 @@ def enforce_pavement_ranges(
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=True,
paved=wanted,
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
@@ -449,7 +453,7 @@ def recompute_designs_for_alignment(
section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")),
ditch_side=stored.get("ditch_side"),
ditch_type=str(stored.get("ditch_type") or "standard"),
paved=paved_at(chainage, paved_ranges, stored.get("paved")),
paved=paved_at(chainage, paved_ranges),
standard=standard,
rock_boundary_offset_m=session_offsets.get(
key,
@@ -527,7 +531,7 @@ def compute_default_designs(
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
ditch_type=str(user.get("ditch_type") or "standard"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage_m, paved_ranges, user.get("paved")),
paved=paved_at(chainage_m, paved_ranges),
standard=standard,
rock_boundary_offset_m=(
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset
+5 -17
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* B06_Section_UI_Cross_Design.ts
* 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식·포장)과
* 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식)과
* 설계선·암 경계선·포장층 오버레이.
*
* 카드 헤더 아래에 세그먼트 버튼을 배치하고, 선택이 바뀌면 onChange로 계산을
@@ -439,25 +439,13 @@ export function buildDesignControls(
!rockCut,
L("B06_Design_Disabled_RockCut"),
);
// 포장 토글: 라벨 삭제, 버튼명 활성="포장"/비활성="비포장"(컬러 유지) — E-7.
const paved = toggle("", state.paved, L("B06_Design_Paved_On"), L("B06_Design_Paved_Off"), () => {
state.paved = !state.paved;
emit();
});
// B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다.
if (design?.pavement_suggested) {
paved.button.title = L("B06_Design_Paved_Suggested");
const badge = document.createElement("span");
badge.className = "b06-design__paved-badge";
badge.textContent = "⚠";
badge.title = L("B06_Design_Paved_Suggested");
paved.wrap.append(badge);
}
// 포장 단추는 없다(138-1) — 포장 = 노면공 · 물넘이 범위만(`pavedBySpecs`) · 「포장 권장」은 알림 줄.
// `state.paved` 는 저장값 그대로 실어 보낸다(재계산이 범위로 다시 정함).
// 오버플로 드롭다운(N-2-2): 컨트롤이 카드 폭에서 한 행을 넘치면 뒤쪽부터 우측 "⋯"
// 드롭다운(세로 배치)으로 옮겨 다음 행으로 밀리는 것을 막는다. 경사 방향은 항상 인라인,
// 이동 우선순위(뒤에서부터): 포장 → 복합경사 → 측구형식 → 측구 토글.
const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap, paved.wrap];
// 이동 우선순위(뒤에서부터): 복합경사 → 측구형식 → 측구 토글.
const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap];
const more = document.createElement("details");
more.className = "b06-design__more";
const moreSummary = document.createElement("summary");
@@ -4,6 +4,7 @@
* (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만.
* 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다.
* ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③).
* ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리).
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
@@ -15,6 +16,7 @@ import {
type FillSlopeWarning,
} from "./B06_Section_UI_Cross_FillSlope_Warn";
import { L, stationLabel } from "./B06_Section_UI_Section_Common";
import { pavedBySpecs } from "./B06_Section_Cross_Refresh";
export interface FillSlopeNotice {
root: HTMLElement;
@@ -46,7 +48,8 @@ function noticeBlock(className: string): {
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`;
});
button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`;
button.textContent =
`${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim();
button.dataset.stationId = section.station_id;
return button;
}),
@@ -59,7 +62,9 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill
const root = document.createElement("div");
const warn = noticeBlock("b06-section__notice");
const info = noticeBlock("b06-section__notice b06-section__notice--info");
root.append(warn.root, info.root);
const pave = noticeBlock("b06-section__notice");
pave.root.title = L("B06_Design_Paved_Suggested");
root.append(warn.root, pave.root, info.root);
root.addEventListener("click", (event) => {
const id = (event.target as HTMLElement).closest("button")?.dataset.stationId;
if (id) onPick(id);
@@ -73,6 +78,13 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill
fillSlopeWarnings(sections, fillSlopeLengths),
stationInterval,
);
pave.fill(
`⚠ ${L("B06_Notice_PaveSuggested")}`,
sections
.filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section))
.map((section) => ({ section, sides: [] })),
stationInterval,
);
info.fill(
`ⓘ ${BERM_INFO_TEXT}`,
bermReviewStations(sections, slopeRunLengths),
@@ -15,6 +15,7 @@
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
const SVG_NS = "http://www.w3.org/2000/svg";
/** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */
@@ -31,6 +32,8 @@ export interface FordPavementSpec {
slope_pct: number | null;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
/** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠. */
chute?: { structure: string; thickness_m: number };
}
interface Edge {
@@ -179,9 +182,34 @@ export function appendFordPavementOverlay(
"b06-chart__ford-deck",
),
);
if (spec.chute) appendChuteBand(svg, [outletFillLine(design)], spec.chute, x, y);
return true;
}
/**
* 유출측(`inflowSign` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라
* 내려가는 동안만(오르면 절토라 멈춤). 성토가 없으면 빈 선(띠 안 그림).
*/
export function outletFillLine(design: CrossDesign): OffsetPoint[] {
const edges = design.road_edges;
if (!edges || !design.design_line?.length) return [];
const outward = design.section_mode !== "right_cut" ? -1 : 1; // +offset = 좌측 · 유입 반대
const edge =
outward > 0
? Math.max(edges.left.offset_m, edges.right.offset_m)
: Math.min(edges.left.offset_m, edges.right.offset_m);
const points = design.design_line
.filter((p) => (p.offset_m - edge) * outward >= -1e-6)
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
const line: OffsetPoint[] = [];
for (const p of points) {
const last = line[line.length - 1];
if (last && p.elevation_m > last.elevation + 1e-6) break;
line.push({ offset: p.offset_m, elevation: p.elevation_m });
}
return line;
}
/**
* 물넘이 자리 노면 · 측구 설계선을 걷는다(66-2 · 66-3) — 노면은 파인 바닥(실선)과 옛 계획고(점선)가
* 대신하고, 측구는 서지 않음. `from` = 설계선 그리기 앞의 자식 수(그 뒤에 붙은 선만 본다).
@@ -6,7 +6,7 @@
* 캐시(`section.culvert`·조정창 제어기)만 고친다 — 영구저장은 [저장]·[확정] 몫이다.
* ========================================================================== */
import { guardSet, pipeDiameterM } from "@util/common_util_culvert_sets";
import { chute, guardSet, pipeDiameterM } from "@util/common_util_culvert_sets";
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
@@ -51,6 +51,7 @@ export function applyPipeOptionsToCache(
patch: Record<string, number | string>,
whole = true,
): void {
syncChute(ctx, chainageM, patch, whole);
// 물넘이포장 — 월류 높이 · 경사각을 캐시 제원에 얹음(66-2 · 66-3) · 폭 절반 걸친 옆 카드도
const paved = (ctx.detail()?.cross_sections ?? []).filter(
(section) =>
@@ -163,6 +164,36 @@ export function applyPipeOptionsToCache(
}
}
/**
* 도수로 · 돌붙임 상자 → 그 관 지점 제원(관 · BOX · 세월교 · 흙막이 · 물넘이 포장)의 `chute` 갈아 끼움
* (136-1) · 바뀐 카드만 다시 그림. 일부만 온 되얹기는 상자가 있을 때만 얹음(없음 = 모름).
*/
function syncChute(
ctx: PipeOptionsContext,
chainageM: number,
patch: Record<string, number | string>,
whole: boolean,
): void {
const found = { chute: chute(patch), pave: chute(patch, "ford_chute") };
for (const section of ctx.detail()?.cross_sections ?? []) {
let changed = false;
for (const [spec, next] of [
[section.culvert, found.chute],
[section.box, found.chute],
[section.ford, found.chute],
[section.ford_pavement, found.pave],
] as const) {
if (!spec || (!whole && !next)) continue;
if (Math.abs((spec.chainage_m ?? section.chainage_m) - chainageM) >= PIPE_MATCH_M) continue;
if (JSON.stringify(spec.chute ?? null) === JSON.stringify(next)) continue;
if (next) spec.chute = { ...next };
else delete spec.chute;
changed = true;
}
if (changed) ctx.refreshCard(section.chainage_m);
}
}
/** 세월교 · 박스 흙막이 세트를 폼 칸 전부로 다시 지음(73-4) — 흙막이가 없으면 걷음. */
function syncBodyGuard(
owner: CrossSection,
@@ -404,13 +404,6 @@
align-self: center;
}
/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */
.b06-design__paved-badge {
font-size: 0.72rem;
color: var(--color-warning);
cursor: help;
}
/* 세월교 구체(2026-08-25) — 바닥판은 콘크리트. 측벽은 ㄴ형 집수정 형상을 그대로
쓰므로 `.b06-chart__culvert-basin` 색을 공유한다. */
.b06-chart__ford-slab {