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
@@ -63,6 +63,35 @@ export function wideningRuns(
return runs;
}
/**
* 확폭 면적(㎡ · 138-2 · 표시만) — 확폭(좌 + 우) 측점 사이 사다리꼴 합 Σ(wᵢ + wᵢ₊₁)/2 × Δ.
* 구간 앞뒤 이웃 측점(확폭 0)까지 이어 붙이고 · 노면공 범위 `[start, end]` 밖은 잘라 냄(자른 끝은 직선 보간).
*/
export function wideningArea(
stations: readonly WideningStation[],
run: WideningRun,
range: { start: number; end: number },
): number {
const sorted = [...stations].sort((a, b) => a.chainage_m - b.chainage_m);
const first = sorted.findIndex((s) => Math.abs(s.chainage_m - run.start_m) <= WIDENING_TOL_M);
const last = sorted.findIndex((s) => Math.abs(s.chainage_m - run.end_m) <= WIDENING_TOL_M);
if (first < 0 || last < 0) return 0;
const points = sorted
.slice(Math.max(first - 1, 0), last + 2)
.map((s) => [s.chainage_m, wideningOf(s)] as const);
let area = 0;
for (let i = 0; i + 1 < points.length; i += 1) {
const [x0, w0] = points[i];
const [x1, w1] = points[i + 1];
const a = Math.max(x0, range.start);
const b = Math.min(x1, range.end);
if (b <= a || x1 <= x0) continue;
const at = (x: number): number => w0 + ((w1 - w0) * (x - x0)) / (x1 - x0);
area += ((at(a) + at(b)) / 2) * (b - a);
}
return area;
}
const round = (value: number): number => Number(value.toFixed(2));
/** 저장 글 → 끈 구간 쌍. 틀린 조각은 버림. */
@@ -10,6 +10,7 @@ import {
isRunOff,
offForRuns,
toggleRun,
wideningArea,
wideningRuns,
type WideningRun,
} from "../A00_Common/design/design_structure_widening";
@@ -37,18 +38,24 @@ export function createWideningBox(hooks: {
let off = "";
/** 지금 범위의 구간들 — 횡단 자료 · 범위가 없으면 null(+ 안내 글). */
const runs = (): { list: WideningRun[] | null; note: string } => {
const runs = (): {
list: WideningRun[] | null;
note: string;
area?: (run: WideningRun) => number;
} => {
const sections = pipeSections();
if (!sections?.length) return { list: null, note: "횡단 자료 없음" };
const { start, end } = hooks.range();
if (start === null || end === null || end <= start)
return { list: null, note: "시작 · 종료 측점을 넣으면 보임" };
const list = wideningRuns(sections, start, end);
return { list, note: list.length ? "" : "범위 안 확폭 없음" };
// 확폭 면적(138-2) — 표시만 · 수량엔 안 넣음
const area = (run: WideningRun) => wideningArea(sections, run, { start, end });
return { list, note: list.length ? "" : "범위 안 확폭 없음", area };
};
const paintBody = (): void => {
const { list, note } = runs();
const { list, note, area } = runs();
if (!list?.length) {
const text = document.createElement("span");
text.className = "b05-structure__widening-note";
@@ -68,6 +75,12 @@ export function createWideningBox(hooks: {
const from = stationLabel(run.start_m, step);
const to = stationLabel(run.end_m, step);
button.textContent = `${from === to ? from : `${from} ~ ${to}`} · 확폭 ${Number(run.widening_m.toFixed(2))} m`;
const size = area?.(run);
const areaText = document.createElement("span");
areaText.className = "b05-structure__widening-area";
areaText.textContent =
size !== undefined && Number.isFinite(size) ? `${size.toFixed(2)} ㎡` : "";
button.append(areaText);
button.title = on ? "확폭 포함 — 누르면 규격 폭만" : "규격 폭만 — 누르면 확폭 포함";
button.addEventListener("click", () => {
off = toggleRun(list, off, run);
@@ -606,3 +606,20 @@
.b05-structure__widening [data-widening-run][aria-pressed="true"]::before {
content: "✓ ";
}
/* 확폭 면적(138-2) — 단추 안 오른쪽 끝 · 끈 구간 회색 */
.b05-structure__widening [data-widening-run] {
display: flex;
align-items: center;
column-gap: 4px;
}
.b05-structure__widening-area {
margin-left: auto;
padding-left: var(--spacing-8, 8px);
white-space: nowrap;
}
.b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area {
color: var(--color-text-secondary);
}
+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 {
@@ -3,8 +3,8 @@
* [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 대피소 자리 · 장 보기를 한 창에 모음.
*
* 왼쪽 = 종류 목록(× 지우기) · 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 · 평면도 장 목록
* 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 장 바꾸기) · [확정 풀기] · [CAD 에서 열기]
* 고치기는 지금 서버 길 그대로(종류 파일 · 확정 풀기) — 고칠 때마다 모달 안을 다시 받고,
* 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 캔버스만 `bare`) · 확정 배지 · [CAD 에서 열기]
* 고치기는 지금 서버 길 그대로(종류 파일) — 고칠 때마다 모달 안을 다시 받고,
* 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음.
* ========================================================================== */
@@ -16,7 +16,6 @@ import {
fetchDesignDrawing,
fetchDesignDrawingList,
fetchPlanKinds,
invalidateDesignDrawing,
type CadDrawing,
type DesignDrawingItem,
type PlanKindsState,
@@ -115,7 +114,7 @@ export function openPlanManager(controls: PlanManageControls): void {
.querySelectorAll<HTMLElement>(".b07-plan-manage__sheets .b07-drawing-button")
.forEach((item) => (item.dataset.active = String(item.dataset.drawingId === picked)));
/** 장 하나를 미리보기에 — 보기 전용 · 확정이면 [확정 풀기](지금 [수정]과 같은 서버 길). */
/** 장 하나를 미리보기에 — 보기 전용 · 확정 풀기는 메인 [수정](140-1). */
const show = async (drawing: DesignDrawingItem, label: string): Promise<void> => {
picked = drawing.id;
markActive();
@@ -127,12 +126,6 @@ export function openPlanManager(controls: PlanManageControls): void {
const badge = el("span", "b07-plan-manage__badge", response.confirmed ? "확정" : "확정 전");
badge.dataset.confirmed = String(response.confirmed);
head.append(badge);
if (response.confirmed)
head.append(
button("b07-plan-tools__button", "확정 풀기", () => {
void run(() => invalidateDesignDrawing(projectId, drawing.id), edits);
}),
);
head.append(button("b07-plan-tools__button", "CAD 에서 열기", () => finish(drawing.id)));
preview.load(
response.drawing,
@@ -146,6 +139,7 @@ export function openPlanManager(controls: PlanManageControls): void {
hostTitle: "평면도 미리보기",
ribbonCollapsed: true,
panelsCollapsed: true,
bare: true,
},
);
} catch (error) {
+4
View File
@@ -58,6 +58,10 @@ button {
.cad-host-save {
--cad-title-height: 0px;
}
.cad-bare {
--cad-title-height: 0px;
--cad-status-height: 0px;
}
.controls {
pointer-events: auto;
}
@@ -13,7 +13,7 @@ import { Ribbon } from '../ribbon/Ribbon';
import { StatusBar } from './StatusBar';
import { useCadRefresh } from './use-cad-refresh';
import { isInspectorCollapsed, isRibbonCollapsed, setInspectorCollapsed } from './ui-state';
import { getActiveToolActor } from '../state';
import { getActiveToolActor, isBare } from '../state';
import type { Tool } from '../tools';
import { ViewControls } from './ViewControls';
import { DropHint } from './DropHint';
@@ -26,21 +26,27 @@ export const Toolbar: FC = () => {
const ribbonCollapsed = isRibbonCollapsed();
// 좌측 패널 접힘 — 호스트(M02 미리보기)가 접힌 채로 실을 수 있게 ui-state 에 둔다
const panelCollapsed = isInspectorCollapsed();
// 캔버스만(B07 평면도 관리 모달) — 겉칸을 안 그리고 높이 · 폭 변수를 0 으로
const bare = isBare();
useEffect(() => {
// 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아
// 두면 스타일에서 넓혀도 이 인라인 값이 덮는다(2026-09-01 실측: 300px 무시됨).
if (panelCollapsed) document.documentElement.style.setProperty('--cad-panel-width', '0px');
if (panelCollapsed || bare) document.documentElement.style.setProperty('--cad-panel-width', '0px');
else document.documentElement.style.removeProperty('--cad-panel-width');
document.documentElement.style.setProperty(
'--cad-command-height',
commandLineVisible ? '86px' : '0px'
commandLineVisible && !bare ? '86px' : '0px'
);
// 리본 접힘 — 탭 줄(26px)만 남기고 패널 줄 자리를 걷는다(`Ribbon.tsx` 가 안 그림).
if (ribbonCollapsed) document.documentElement.style.setProperty('--cad-ribbon-height', '26px');
if (bare) document.documentElement.style.setProperty('--cad-ribbon-height', '0px');
else if (ribbonCollapsed)
document.documentElement.style.setProperty('--cad-ribbon-height', '26px');
else document.documentElement.style.removeProperty('--cad-ribbon-height');
window.dispatchEvent(new Event('resize'));
}, [panelCollapsed, commandLineVisible, ribbonCollapsed]);
}, [panelCollapsed, commandLineVisible, ribbonCollapsed, bare]);
if (bare) return null;
return (
<>
@@ -27,6 +27,7 @@ import {
setLayers,
setOriginMarker,
setViewOnly,
setBare,
} from '../state.ts';
import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts';
import { toast } from 'react-toastify';
@@ -68,6 +69,8 @@ interface DrawingLoadMessage {
panelsCollapsed?: boolean;
/** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
viewOnly?: boolean;
/** 캔버스만 — 제목줄 · 리본 · 패널 · 상태막대를 안 그림(B07 평면도 관리 모달). */
bare?: boolean;
originMarker?: boolean;
}
@@ -227,6 +230,7 @@ export function registerAisloDrawingBridge() {
setRibbonCollapsed(event.data.ribbonCollapsed === true);
if (event.data.panelsCollapsed) setInspectorCollapsed(true);
setViewOnly(event.data.viewOnly === true);
setBare(event.data.bare === true);
setOriginMarker(event.data.originMarker === true);
if (event.data.frameEdit) applyFramePreview();
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
@@ -7,9 +7,11 @@ import {
getEntities,
getHighlightedEntityIds,
isDrawingDirty,
isBare,
isDrawingReadOnly,
resetUndoBaseline,
setActiveLayerId,
setBare,
setDesignMeta,
setEntities,
setHighlightedEntityIds,
@@ -120,3 +122,23 @@ describe('isDrawingReadOnly', () => {
expect(isDrawingReadOnly()).toBe(false);
});
});
describe('setBare', () => {
it('캔버스만 — 켜면 뿌리에 cad-bare(제목줄 · 상태막대 0), 끄면 걷힌다', () => {
stubWindow();
const classes = new Set<string>();
(globalThis as { document?: unknown }).document = {
documentElement: {
classList: {
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
},
},
};
setBare(true);
expect(isBare()).toBe(true);
expect(classes.has('cad-bare')).toBe(true);
setBare(false);
expect(isBare()).toBe(false);
expect(classes.has('cad-bare')).toBe(false);
});
});
+9
View File
@@ -197,6 +197,8 @@ let viewOnly = false;
let originMarker = false;
let hostTitle = 'B07 상세 설계';
let hostSave = false;
/** 캔버스만(B07 평면도 관리 모달) — 제목줄 · 리본 · 패널 · 상태막대를 안 그린다. */
let bare = false;
/**
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
@@ -512,6 +514,13 @@ export const setHostSave = (enabled: boolean) => {
// 제목 줄이 없어지면 그 높이만큼 위로 당긴다(App.css `.cad-host-save`).
document.documentElement.classList.toggle('cad-host-save', enabled);
};
export const isBare = (): boolean => bare;
export const setBare = (enabled: boolean) => {
bare = enabled;
// 제목 줄 · 상태막대 높이를 걷는다(App.css `.cad-bare`) — 리본 · 패널은 Toolbar 가 0 으로.
document.documentElement.classList.toggle('cad-bare', enabled);
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const getHostTitle = (): string => hostTitle;
export const setHostTitle = (title: string) => {
hostTitle = title;
+8 -2
View File
@@ -240,6 +240,10 @@ def _revets(
)
# 도수로/돌붙임 잇기 → 옵션 머리 — 물넘이 포장(`ford_chute` · 136-2)도 같은 열
CHUTE_HEADS = {"gchuteadd": "chute_add", "gfordpavechute": "ford_chute"}
def _chutes(
sheet: _Sheet,
at: float,
@@ -249,9 +253,10 @@ def _chutes(
) -> None:
"""돌붙임 — 붙임 형식 · 면적이 다 있을 때만(설계자가 넣음)."""
for link, combo in links.items():
if not link.startswith("gchuteadd"):
tag = next((t for t in CHUTE_HEADS if link.startswith(t)), None)
if not tag:
continue
head = f"chute_add_{link[len('gchuteadd') :]}_"
head = f"{CHUTE_HEADS[tag]}_{link[len(tag) :]}_"
bond = str(options.get(f"{head}bond") or "")
area = _num(options.get(f"{head}area"))
if options.get(f"{head}structure") != "돌붙임" or not bond or not area:
@@ -449,6 +454,7 @@ def _drain(
combos = {part: links.get(link) or _combo(group.get(link)) for part, link in required.items()}
if type_id == "ford_pavement":
_pavement(sheet, at, base, combos, names)
_chutes(sheet, at, options, links, pipe_names)
return
if type_id == "box_culvert":
empty_height = _box(sheet, at, base, combos, names, box_length)
+3 -1
View File
@@ -1,7 +1,7 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬,
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?, 승격?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
@@ -17,6 +17,7 @@
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
같은 품목 두 번은 [저장] 때 400.
승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫.
"""
from __future__ import annotations
@@ -124,6 +125,7 @@ def clean(doc: Any) -> dict[str, Any]:
"수량": _qty(one.get("수량"), where),
**({"켜쌓기율": rate} if rate is not None else {}),
**({"고름": picks} if picks else {}),
**({"승격": True} if one.get("승격") is True else {}),
}
)
mats, codes = [], set()
@@ -204,6 +204,16 @@
color: var(--color-text-secondary);
}
/* 구분 드롭다운(134-1) — 칸 너비만 · 품셈 책 글은 아래 */
.m02-labrow__kind .ui-field {
display: block;
}
.m02-labrow__kind .ui-select {
width: auto;
min-width: 0;
}
/* 줄 접고 폄 표(51-2) · 고른 줄 하위 행 — 이름 들여쓰기 · 흐린 바탕 · 위 줄보다 촘촘(51-7) */
.m02-labrow .m02-labrow__sub td {
padding: var(--spacing-4) var(--spacing-8);
@@ -133,6 +133,8 @@ export interface LaborRow {
켜쌓기율?: number | null;
/** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */
고름?: Record<string, string>;
/** 단가승격(134-1 · 로직 줄 구분 드롭다운) — 없음 = 단가산출 · 금액 무관 · B09 호표 몫 */
승격?: boolean;
}
export interface LaborMat {
코드: string;
@@ -170,6 +172,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
...(r.참조 ? { 참조: r.참조 } : {}),
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}),
...(r.승격 === true && !r.참조 ? { 승격: true } : {}),
})),
자재: (d.자재 ?? []).map((m) => ({
코드: m.코드,
@@ -204,6 +204,8 @@ export function mountLaborDetail(
move: reorder,
remove: removeRow,
alias: setAlias,
// 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음)
...(lib ? {} : { promote: setPromote }),
}
: { open: pick, option: openRow, ref: openRef },
),
@@ -361,6 +363,12 @@ export function mountLaborDetail(
cur.담은로직[i].별칭 = value;
opt.onChanged(dirty());
}
/** 구분 드롭다운(134-1) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 */
function setPromote(i: number, on: boolean): void {
if (on) cur.담은로직[i].승격 = true;
else delete cur.담은로직[i].승격;
opt.onChanged(dirty());
}
function removeMat(i: number): void {
cur.자재.splice(i, 1);
changed();
@@ -12,7 +12,7 @@
* 상세는 줄 누름 = 고름(`selected` 줄 하이라이트 · 그 줄 바로 아래 하위 행 · 51-2 · 화살표 없음 51-7) · 넣을 값 모달은 [옵션](47-5).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type {
@@ -41,14 +41,36 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭(113-1 · 나머지 칸은 위 줄에서 rowspan 2) */
const SPAN = 3;
/** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */
export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
/** 구분(50-4) — 로직 줄 = `승격`(134-1 · 단가승격 · 없음 = 단가산출) · 참조 줄 = 키 머리(SC- = 일위조합 ·
* 옛 SP- = 단가승격 글 고정 · 134-2) */
export const kindOf = (row: Pick<LaborRow, "로직" | "참조" | "승격">): string => {
const key = row.참조?.키 ?? row.로직;
return tc(
key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic",
row.승격 || key.startsWith("SP-")
? "Kind_Promote"
: key.startsWith("SC-")
? "Kind_Combo"
: "Kind_Logic",
);
};
/** 구분 드롭다운(134-1) — 로직 줄만 [단가산출 | 단가승격] · 줄 누름과 따로 */
function kindPick(on: boolean, set: (on: boolean) => void): HTMLElement {
const pick = createSelectField({
options: [
{ value: "", text: tc("Kind_Logic") },
{ value: "1", text: tc("Kind_Promote") },
],
value: on ? "1" : "",
compact: true,
ariaLabel: tc("Sub"),
onChange: (v) => set(v === "1"),
});
pick.select.dataset.laborKind = "";
pick.root.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(고름)과 따로
return pick.root;
}
/** 품셈 책(102-3) — 출처 첫 낱말 */
const BOOK = /^(건설|산림)품셈/;
@@ -189,6 +211,8 @@ export interface RowHandlers {
remove?: (i: number) => void;
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
alias?: (i: number, value: string | null) => void;
/** 구분 드롭다운(134-1 · 로직 줄 단가산출 ↔ 단가승격) — 없으면 글만 */
promote?: (i: number, on: boolean) => void;
}
/** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */
@@ -361,7 +385,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
el("td", {
className: "m02-labrow__kind",
children: [
kindOf(row),
on?.promote && !row.참조
? kindPick(!!row.승격, (v) => on.promote?.(i, v))
: kindOf(row),
// 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말)
...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []),
],
+27 -17
View File
@@ -126,27 +126,33 @@ function sideSpec(
return spec;
}
/** 도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) · 구조를 안
* 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */
/** 도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 · 두께
* (cm → m · 빈 값 30 cm) · 구조를 안 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */
export function chute(
values: Record<string, unknown>,
head = "chute_add",
): { structure: string; thickness_m: number } | null {
const pattern = new RegExp(`^${head}_(\\d+)_structure$`);
const picked = Object.entries(values)
.flatMap(([key, value]) => {
const hit = /^chute_add_(\d+)_structure$/.exec(key);
const hit = pattern.exec(key);
const structure = String(value ?? "").trim();
return hit && structure ? [[Number(hit[1]), structure] as const] : [];
})
.sort((a, b) => a[0] - b[0]);
if (!picked.length) return null;
const [n, structure] = picked[0];
const cm = num(values[`chute_add_${n}_thickness`], null);
const cm = num(values[`${head}_${n}_thickness`], null);
return { structure, thickness_m: round((cm && cm > 0 ? cm : CHUTE_THICKNESS_CM) / 100, 3) };
}
/** 도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2). */
function withChute(spec: CulvertSetSpec, values: Record<string, unknown>): CulvertSetSpec {
const found = chute(values);
function withChute(
spec: CulvertSetSpec,
values: Record<string, unknown>,
head = "chute_add",
): CulvertSetSpec {
const found = chute(values, head);
if (found) spec.chute = found;
return spec;
}
@@ -417,17 +423,21 @@ function fordPavementSet(
: null;
const used = degree ?? fallback;
const span = width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M;
return {
type: "ford_pavement",
span_m: span,
// 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
depth_m: depth && depth > 0 ? depth : null,
slope_pct: used !== null ? round(Math.tan((used * Math.PI) / 180) * 100, 4) : legacy,
defaulted: defaulted(values, [
["ford_width_m", "월류 폭", span],
["ford_slope_deg", "경사각", fallback],
]),
};
return withChute(
{
type: "ford_pavement",
span_m: span,
// 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
depth_m: depth && depth > 0 ? depth : null,
slope_pct: used !== null ? round(Math.tan((used * Math.PI) / 180) * 100, 4) : legacy,
defaulted: defaulted(values, [
["ford_width_m", "월류 폭", span],
["ford_slope_deg", "경사각", fallback],
]),
},
values,
"ford_chute",
);
}
/** BOX암거 1개소의 세트 제원(구체 + 날개벽 연장). */
@@ -320,7 +320,7 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan
confirms: confirms.length,
};
// 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 → [확정 풀기])
// 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 배지만 · [확정 풀기] 없음 140-1)
const pickSheet = async (id) => {
modal.body
.querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button")
@@ -340,14 +340,6 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan
};
await pickSheet("plan_k_route");
out.routeHead = head().textContent;
byText(head(), "BUTTON", "확정 풀기")[0].click();
await settle();
await settle();
out.release = {
call: calls.find((c) => c[0] === "invalidate"),
head: head().textContent,
preview: cad.loads.at(-1).drawing.entities[0].id,
};
// 닫기 — 고친 게 있으니 changed = true · 미리보기 듣기 멈춤
modal.opts.beforeClose();
@@ -0,0 +1,96 @@
/* 136-1 폼 → 캐시 chute 헬퍼 — `applyPipeOptionsToCache`(B06_Section_UI_Page_Pipe_Options.ts)를 그 자리에서
* 트랜스파일해 돌림 · 도수로/돌붙임 상자가 관 · BOX · 세월교 · 물넘이 포장 제원 `chute` 에 바로 얹히는지 ·
* 다시 그린 카드. 짝 = `test_136_chute_form_cache.py`. 로더는 `helper_b06_draft_pipe_replay.cjs` 와 같음.
*/
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();
const session = new Map();
const fakeStorage = {
getItem: (k) => session.get(k) ?? null,
setItem: (k, v) => session.set(k, String(v)),
removeItem: (k) => session.delete(k),
};
globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage };
globalThis.sessionStorage = fakeStorage;
globalThis.localStorage = fakeStorage;
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 localRequire = (request) => {
if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} });
if (request.startsWith("@util/"))
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
if (request.startsWith("@config/"))
return loadTs(path.join(ROOT, "config", `${request.slice(8)}.ts`));
return request.startsWith(".")
? loadTs(path.join(path.dirname(full), `${request}.ts`))
: require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const options = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"));
const box = (at) => ({
chainage_m: 120,
top_thickness_m: 0.3,
inner_height_m: 2,
slab_thickness_m: 0.3,
wing_in: {},
wing_out: {},
_at: at,
});
const detail = {
cross_sections: [
{ chainage_m: 85, culvert: { chainage_m: 85.05, diameter_m: 1, inlet: {}, outlet: {} } },
{ chainage_m: 118, box: box(118) },
{ chainage_m: 120, box: box(120) },
{ chainage_m: 160, ford: { chainage_m: 160, diameter_m: 1, wing_in: {}, wing_out: {} } },
{ chainage_m: 196, ford_pavement: { chainage_m: 200, span_m: 9, depth_m: 0.3 } },
{ chainage_m: 200, ford_pavement: { chainage_m: 200, span_m: 9, depth_m: 0.3 } },
],
};
let refreshed = [];
const ctx = {
detail: () => detail,
ford: { setPipe: () => {}, setWing: () => {} },
box: { setBody: () => {}, setWing: () => {} },
revetOffset: { update: () => {}, adjustFor: () => ({ h: null, m: null }) },
overrideOptions: () => {},
refreshCard: (at) => refreshed.push(at),
};
const at = (i) => detail.cross_sections[i];
const step = (chainage, patch, whole = true) => {
refreshed = [];
options.applyPipeOptionsToCache(ctx, chainage, patch, whole);
return {
pipe: at(0).culvert.chute ?? null,
box: [at(1).box.chute ?? null, at(2).box.chute ?? null],
ford: at(3).ford.chute ?? null,
pave: [at(4).ford_pavement.chute ?? null, at(5).ford_pavement.chute ?? null],
refreshed: [...new Set(refreshed)].sort((a, b) => a - b),
};
};
const out = {
pipeAdd: step(85.05, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 40 }),
pipeSame: step(85.05, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 40 }),
replayNoChute: step(85.05, { pipe_diameter_mm: 1000 }, false),
pipeRemove: step(85.05, { pipe_diameter_mm: 1000 }),
boxAdd: step(120, { chute_add_1_structure: "도수로" }),
fordAdd: step(160, { chute_add_1_structure: "돌붙임", chute_add_1_thickness: 50 }),
paveAdd: step(200, { ford_chute_1_structure: "돌붙임", chute_add_1_structure: "도수로" }),
};
process.stdout.write(JSON.stringify(out));
+13
View File
@@ -50,5 +50,18 @@ const out = {
toggledOn: w.toggleRun(runs, off, runs[0]),
stale: w.offForRuns(runs, "20-60,0-5,100-130"),
empty: w.offForRuns([], "20-60"),
// 확폭 면적(138-2) — 앞뒤 확폭 0 측점까지 사다리꼴 · 범위로 자름
area: runs.map((r) => w.wideningArea(stations, r, { start: 0, end: 140 })),
areaClipped: w.wideningArea(stations, runs[0], { start: 30, end: 140 }),
areaSingle: w.wideningArea(
[at(0), at(20, 1, 0), at(40)],
{ start_m: 20, end_m: 20, widening_m: 1 },
{ start: 0, end: 40 },
),
areaMissing: w.wideningArea(
stations,
{ start_m: 999, end_m: 999, widening_m: 1 },
{ start: 0, end: 140 },
),
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,22 @@
/* M02 일위대가 문서 화면 정리(`M02_MasterTemplete_Labor_Api.ts` `normalizeLabor`) 헬퍼(134-1) —
* 그 자리에서 트랜스파일해 Node 로 돌림 · 가져오는 길은 빈 대역 · stdin = {doc, column} · stdout = 정리한 문서 JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(
__dirname,
"..",
"..",
"M02_MasterTemplete",
"M02_MasterTemplete_Labor_Api.ts",
);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const stub = new Proxy({}, { get: () => function () {} });
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify(box.exports.normalizeLabor(input.doc, input.column)));
@@ -82,7 +82,14 @@ POINTS = [
{
"chainage_m": 500.0,
"facility": "ford_pavement",
"options": {"ford_width_m": 8, "ford_height_m": 1.0},
"options": {
"ford_width_m": 8,
"ford_height_m": 1.0,
# 물넘이 포장 도수로/돌붙임(136-2) — 관 · 박스 · 세월교와 같은 열
"ford_chute_1_structure": "돌붙임",
"ford_chute_1_bond": "찰붙임",
"ford_chute_1_area": 12,
},
},
]
STRUCTURES = [
@@ -177,6 +184,7 @@ def test_물넘이_H별_월류폭(out):
pave = _col(out, "pipe:pave|1.0")
assert pave["group"] == ["물넘이포장", "월류 폭", "H=1.0"] and pave["label"] == "m"
assert _cell(out, 500, "pipe:pave|1.0") == 8
assert _cell(out, 500, "pipe:chute|찰붙임") == 12
def test_개거_노출형_맹암거_개소(out):
+6 -7
View File
@@ -5,7 +5,7 @@
- 좌측 「평면도」 = 장 단추 + [평면도 관리] 하나 · 종류 폼 · 대피소 칸 · × 없음.
- 단추 → 공용 모달(큰 창) · 안에 종류 목록 · 종류 폼 · 대피소 · 사토장 자리 · 장 목록 · 첫 장 미리보기(보기 전용).
- 종류 추가 · 업데이트(축척 포함) · 지우기 = 지금 서버 길 · 고칠 때마다 모달 안을 다시 받음.
- 미리보기 장 바꾸기 · 확정이면 [확정 풀기](invalidate) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id.
- 미리보기 장 바꾸기 · 캔버스만(bare) · 확정 배지만(확정 풀기 없음 140-1) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id.
Node 헬퍼 `helper_130_b07_plan_manage.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인.
"""
@@ -54,6 +54,7 @@ def test_첫_장_미리보기는_보기_전용(out):
assert out["open"]["preview"] == "plan_k_terrain"
extra = out["open"]["extra"]
assert extra["readOnly"] is True and extra["viewOnly"] is True
assert extra["bare"] is True # 140-1 캔버스만(제목줄 · 리본 · 상태막대 없음)
assert extra["recoveryScope"] == "b07-plan-preview" # 본 CAD 자동백업 칸과 안 겹침
@@ -82,12 +83,10 @@ def test_미리보기_장_바꾸기(out):
assert "확정 전" in s2["head"] and "확정 풀기" not in s2["head"]
def test_확정이면_확정_풀기(out):
assert "확정 풀기" in out["routeHead"]
rel = out["release"]
assert rel["call"] == ["invalidate", "plan_k_route"]
assert "확정 풀기" not in rel["head"]
assert rel["preview"] == "plan_k_route" # 보던 장 그대로
def test_확정이면_배지만(out):
# 140-1 확정 풀기는 메인 [수정] — 모달엔 배지 · [CAD 에서 열기]만
assert "확정" in out["routeHead"] and "CAD 에서 열기" in out["routeHead"]
assert "확정 풀기" not in out["routeHead"]
def test_닫으면_좌측_다시_읽기_신호(out):
@@ -0,0 +1,55 @@
"""136-1 폼 → 캐시 chute — 좌측 패널 도수로/돌붙임 상자가 [저장] 전에 횡단 제원에 바로 얹힘.
관 · BOX(폭 걸친 측점 둘) · 세월교 · 물넘이 포장(`ford_chute_*`) · 지움 · 일부만 온 되얹기.
화면 TS 를 그 자리에서 돌린 값(`helper_136_chute_form_cache.cjs`).
"""
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_136_chute_form_cache.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_관_상자_넣으면_바로_chute_와_카드(out: dict) -> None:
assert out["pipeAdd"]["pipe"] == {"structure": "돌붙임", "thickness_m": 0.4}
assert 85 in out["pipeAdd"]["refreshed"]
# 같은 값이면 chute 때문에 다시 그리진 않음(관 길의 카드 갱신만)
assert out["pipeSame"]["pipe"] == out["pipeAdd"]["pipe"]
def test_되얹기는_상자_없으면_그대로_칸_전부면_지움(out: dict) -> None:
assert out["replayNoChute"]["pipe"] == {"structure": "돌붙임", "thickness_m": 0.4}
assert out["pipeRemove"]["pipe"] is None
def test_박스는_걸친_측점_모두_세월교도(out: dict) -> None:
assert out["boxAdd"]["box"] == [{"structure": "도수로", "thickness_m": 0.3}] * 2
assert {118, 120} <= set(out["boxAdd"]["refreshed"])
assert out["fordAdd"]["ford"] == {"structure": "돌붙임", "thickness_m": 0.5}
assert out["fordAdd"]["refreshed"] == [160]
# 다른 관 지점은 안 건드림
assert out["fordAdd"]["pipe"] is None and out["fordAdd"]["box"][0] is not None
def test_물넘이_포장은_ford_chute_만(out: dict) -> None:
assert out["paveAdd"]["pave"] == [{"structure": "돌붙임", "thickness_m": 0.3}] * 2
assert out["paveAdd"]["refreshed"] == [196, 200]
@@ -131,3 +131,70 @@ def test_정의_그룹_경사각도():
) as f:
groups = json.load(f)["그룹"]["ford_pavement"]
assert [(g["id"], g["이름"]) for g in groups] == [("gfordpaveslope", "경사각도")]
OVERLAY = r"""
const fs = require("fs"), path = require("path");
const ROOT = process.argv[1];
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const cache = new Map();
const load = (file) => {
if (cache.has(file)) return cache.get(file);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(file, box.exports);
const req = (r) => (r.startsWith(".") ? load(path.join(path.dirname(file), `${r}.ts`)) : ({}));
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
};
class Node {
constructor(tag) { this.tag = tag; this.attrs = {}; this.children = []; }
setAttribute(k, v) { this.attrs[k] = String(v); }
getAttribute(k) { return this.attrs[k] ?? null; }
append(...kids) { this.children.push(...kids); }
set textContent(v) { this.text = v; }
}
globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) };
const pave = load(path.join(ROOT, "B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts"));
// +offset = 좌측 · 좌 성토 1:1.5 · 우 절토 오름 — 유입 = 좌(left_cut 아님) → 유출 = 우
const design = (mode) => ({
section_mode: mode, cross_slope_pct: 2, design_elevation_m: 100, pavement_thickness_m: 0.2,
road_edges: { left: { offset_m: 2.5, elevation_m: 100 }, right: { offset_m: -2.5, elevation_m: 100 } },
design_line: [
{ offset_m: 7, elevation_m: 97 }, { offset_m: 2.5, elevation_m: 100 },
{ offset_m: -2.5, elevation_m: 100 }, { offset_m: -5.5, elevation_m: 98 },
{ offset_m: -6.5, elevation_m: 98 }, { offset_m: -8, elevation_m: 99 },
],
});
const spec = { span_m: 9, depth_m: 0.3, slope_pct: 2, chute: { structure: "돌붙임", thickness_m: 0.3 } };
const run = (mode, withChute = true) => {
const svg = new Node("g");
pave.appendFordPavementOverlay(svg, withChute ? spec : { ...spec, chute: undefined }, design(mode), (o) => o, (e) => e);
const band = svg.children.find((k) => k.attrs.class === "b06-chart__culvert-pitching");
return band ? band.attrs.points : null;
};
process.stdout.write(JSON.stringify({
outLeft: pave.outletFillLine(design("left_cut")), outRight: pave.outletFillLine(design("right_cut")),
band: run("left_cut"), none: run("left_cut", false),
}));
"""
def test_유출측_성토사면_띠_136_2():
proc = subprocess.run(
["node", "-e", OVERLAY, ROOT], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert proc.returncode == 0, proc.stderr
got = json.loads(proc.stdout)
# 유입 = 좌 → 유출 = 우(− 바깥) · 노견에서 내려가는 동안만(평지 · 오름에서 멈춤)
assert got["outLeft"] == [
{"offset": -2.5, "elevation": 100},
{"offset": -5.5, "elevation": 98},
{"offset": -6.5, "elevation": 98},
]
# right_cut = 유입 우 → 유출 좌 성토 1:1.5
assert got["outRight"] == [{"offset": 2.5, "elevation": 100}, {"offset": 7, "elevation": 97}]
assert got["band"] and got["band"].startswith("-2.5,100 -5.5,98")
assert got["none"] is None
@@ -25,7 +25,8 @@ def test_이월은_사용자_입력만() -> None:
"ditch_side",
):
assert result_key not in CARRY_INPUT_KEYS
assert {"ground_type", "paved", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS)
assert {"ground_type", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS)
assert "paved" not in CARRY_INPUT_KEYS # 포장 = 노면공 범위로 새로 정함(138-1)
def test_체인이_옛_설계를_통째로_옮기지_않는다() -> None:
+16
View File
@@ -58,3 +58,19 @@ def test_끈_구간_글_왕복(out):
def test_안_맞는_끈_값은_무시_저장_때_빠짐(out):
assert out["stale"] == "20-60"
assert out["empty"] == ""
def test_확폭_면적_사다리꼴_앞뒤_0_측점까지(out):
# 0~20: (0+0.3)/2×20=3 · 20~40: 10.5 · 40~60: 11.5 · 60~80: 4 → 29
# 80~100: 5 · 100~120: 11 · 120~140: 6 → 22
assert [round(a, 6) for a in out["area"]] == [29.0, 22.0]
def test_확폭_면적_노면공_범위로_자름(out):
# 30 에서 자름: w(30)=0.525 → (0.525+0.75)/2×10=6.375 + 11.5 + 4 = 21.875
assert round(out["areaClipped"], 6) == 21.875
def test_한_측점_구간도_면적_자료_없으면_0(out):
assert round(out["areaSingle"], 6) == 20.0 # (0+1)/2×20 × 2
assert out["areaMissing"] == 0
@@ -100,7 +100,14 @@ POINTS = [
{
"chainage_m": 140.0,
"facility": "ford_pavement",
"options": {"ford_height_m": 0.3, "ford_slope_deg": 5},
"options": {
"ford_height_m": 0.3,
"ford_slope_deg": 5,
# 물넘이 포장 도수로/돌붙임(136-2) — `ford_chute_*` · 관 키(`chute_add_*`)는 안 읽음
"ford_chute_1_structure": "돌붙임",
"ford_chute_1_thickness": 40,
"chute_add_1_structure": "도수로",
},
},
{"chainage_m": 160.0, "facility": "ford_pavement", "options": {"ford_height_m": 0.2}},
]
@@ -213,3 +220,5 @@ def test_ford_box_guard_attached_as_culvert() -> None:
assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3}
assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45}
assert "chute" not in sections[40.0]["culvert"]
assert sections[140.0]["ford_pavement"]["chute"] == {"structure": "돌붙임", "thickness_m": 0.4}
assert "chute" not in sections[160.0]["ford_pavement"]
+32 -7
View File
@@ -126,17 +126,42 @@ def test_missing_truth_files_are_not_an_error(tmp_path):
# ── 포장 판정 규칙 ─────────────────────────────────────────────────────────
def test_outside_range_keeps_user_value_and_defaults_unpaved():
"""경사 제안은 더 이상 포장을 켜지 않는다 — 구간 밖은 사용자 값(없으면 비포장)."""
def test_outside_range_is_unpaved():
"""포장 = 노면공 · 물넘이 범위만(138-1) — 밖은 비포장(옛 저장값 인자 없음)."""
ranges = [(10.0, 20.0)]
assert paved_at(30.0, ranges) is False
assert paved_at(30.0, ranges, True) is True
assert paved_at(30.0, ranges, False) is False
assert paved_at(15.0, ranges) is True
assert paved_at(20.02, ranges) is True # 끝 허용오차 = 브라우저 짝 0.02 m
assert paved_at(20.03, ranges) is False
assert paved_at(15.0, []) is False
def test_inside_range_overrides_user_off_switch():
"""구간 안은 강제 포장 — 물넘이는 콘크리트 노면이고 지정 구간은 자동 반영이다."""
assert paved_at(15.0, [(10.0, 20.0)], False) is True
def test_enforce_turns_paved_on_and_off(monkeypatch):
"""재계산은 켬↔끔 양쪽 — 범위 밖 옛 `paved=true` 는 비포장으로 되돌림(138-1)."""
from pathlib import Path
from B06_Section import B06_Section_Router_Design as design_mod
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 50.0)])
monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: [])
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
monkeypatch.setattr(
design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]}
)
sections = [
{"chainage_m": 20.0, "design": {"paved": False}}, # 범위 안 · 끔 → 켬
{"chainage_m": 80.0, "design": {"paved": True}}, # 범위 밖 · 켬 → 끔
{"chainage_m": 30.0, "design": {"paved": True}}, # 그대로
{"chainage_m": 90.0, "design": {"paved": False}}, # 그대로
]
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2
assert [s["design"]["paved"] for s in sections] == [True, False, True, False]
# 노면공을 다 지워도(범위 없음) 옛 켬은 꺼진다
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [])
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2
assert not any(s["design"]["paved"] for s in sections)
# ── 독립 기슭막이 ─────────────────────────────────────────────────────────
@@ -0,0 +1,90 @@
"""일위대가 로직 줄 단가승격 표시(PLAN 134-1) — 줄에 `승격: true` · 금액 무관.
- 서버 `clean` = `true` 만 남김(거짓 · 글 · 참조 줄은 버림) · 저장 왕복에 남음 · 금액 그대로
- 사전조합 안 로직 줄엔 승격 없음(정리 때 버림)
- 화면 `normalizeLabor` 도 `true` 만 통과(Node 헬퍼)
"""
import json
import os
import subprocess
import pytest
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_structure import write_sample_table
HERE = os.path.dirname(os.path.abspath(__file__))
NET = "GC000337"
BARE = "GC000994"
def _doc(lines):
return {"열": "rv", "담은로직": lines, "자재": []}
def test_clean_은_참만_남김():
got = labor.clean(_doc([{"로직": NET, "승격": True}, {"로직": BARE, "승격": "yes"}]))[
"담은로직"
]
assert got[0]["승격"] is True and "승격" not in got[1]
def test_승격은_금액을_안_바꿈():
plain = labor.calc(labor.clean(_doc([{"로직": NET, "수량": {"손": 2}}])), {})
flag = labor.calc(labor.clean(_doc([{"로직": NET, "수량": {"손": 2}, "승격": True}])), {})
assert flag["반영합"] == plain["반영합"] and flag["기준합"] == plain["기준합"]
def test_사전조합_안_로직_줄엔_승격_없음(monkeypatch):
monkeypatch.setattr(refs, "here", lambda: ("system", None))
lines = refs._combo_lines({"담은로직": [{"로직": NET, "승격": True}]}, "SC-000009")
assert "승격" not in lines[0]
@pytest.fixture()
def system(tmp_path, monkeypatch):
write_sample_table(tmp_path)
monkeypatch.setattr(store, "FOLDER", tmp_path)
store.create_structure("rv")
return tmp_path
def test_저장_왕복에_승격_남음(system):
have = store.read("labor", "rv")
sent = {
**have["문서"],
"담은로직": [
{"로직": NET, "수량": None, "승격": True},
{"로직": BARE, "수량": None},
],
}
store.write("labor", "rv", have["판"], sent)
rows = store.read("labor", "rv")["문서"]["담은로직"]
assert rows[0]["승격"] is True and "승격" not in rows[1]
# 내리면(드롭다운 단가산출) 칸째 사라짐
have = store.read("labor", "rv")
store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": NET}]})
assert "승격" not in store.read("labor", "rv")["문서"]["담은로직"][0]
def test_화면_정리도_참만_통과():
doc = {
"담은로직": [
{"로직": NET, "승격": True},
{"로직": BARE, "승격": False},
{"로직": "", "참조": {"층": "system", "주인": None, "키": "SC-000001"}, "승격": True},
]
}
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_normalize.cjs")],
input=json.dumps({"doc": doc, "column": "rv"}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
rows = json.loads(proc.stdout)["담은로직"]
assert [r.get("승격") for r in rows] == [True, None, None]
+2
View File
@@ -77,6 +77,8 @@ export interface CadLoadExtra {
panelsCollapsed?: boolean;
/** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
viewOnly?: boolean;
/** 캔버스만 — 제목줄 · 리본 · 패널 · 상태막대를 안 그린다(B07 평면도 관리 모달). */
bare?: boolean;
/** 좌표 0,0 에 기준점 십자를 그린다(그리기 겹판 · 저장 안 됨) — 안 보내면 없음. */
originMarker?: boolean;
}
+1 -3
View File
@@ -447,9 +447,6 @@ export const ui_locales_b2 = {
B06_Design_DitchType_Legend: ["측구형식", "Ditch type"],
B06_Design_DitchType_Standard: ["일반", "Standard"],
B06_Design_DitchType_LType: ["L형", "L-type"],
B06_Design_Paved_Legend: ["포장", "Pavement"],
B06_Design_Paved_On: ["포장", "Paved"],
B06_Design_Paved_Off: ["비포장", "Unpaved"],
B06_Design_TwoStage_Legend: ["2단계 경사", "Two-stage slope"],
B06_Design_TwoStage_On: ["복합경사", "Compound slope"],
B06_Design_TwoStage_Off: ["단경사", "Single slope"],
@@ -475,6 +472,7 @@ export const ui_locales_b2 = {
"측구를 생성한 경우에만 형식을 선택할 수 있습니다.",
"Available only when a ditch is created.",
],
B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"],
B06_Design_Paved_Suggested: [
"종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)",
"Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",