Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1
This commit is contained in:
@@ -103,6 +103,16 @@ class _SectionGeometry:
|
||||
low_left = ground_at(self.left_extent) < ground_at(-self.right_extent)
|
||||
self.slope_per_offset = -slope if low_left else slope
|
||||
|
||||
# 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 노견(폭 고정) · 측구 · 사면이
|
||||
# 같이 바깥으로 · 늘어난 폭은 차도 · 단면유형 · 측구 판정은 **옮긴 뒤** 노견에서(아래 ·
|
||||
# 370-1 — 마루를 넘으면 그 쪽 사면 기울기로 원지반까지). ⚠ TS 짝 `SectionGeometry`.
|
||||
self.shoulder_extend_left = max(shoulder_extend_left_m, 0.0)
|
||||
self.shoulder_extend_right = max(shoulder_extend_right_m, 0.0)
|
||||
self.half_road_left += self.shoulder_extend_left
|
||||
self.half_road_right += self.shoulder_extend_right
|
||||
self.left_extent += self.shoulder_extend_left
|
||||
self.right_extent += self.shoulder_extend_right
|
||||
|
||||
# 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다
|
||||
# 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서
|
||||
# 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고
|
||||
@@ -156,16 +166,6 @@ class _SectionGeometry:
|
||||
if tilted:
|
||||
self.has_ditch = False
|
||||
|
||||
# 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
|
||||
# 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도.
|
||||
# ⚠ TS 짝 `SectionGeometry`.
|
||||
self.shoulder_extend_left = max(shoulder_extend_left_m, 0.0)
|
||||
self.shoulder_extend_right = max(shoulder_extend_right_m, 0.0)
|
||||
self.half_road_left += self.shoulder_extend_left
|
||||
self.half_road_right += self.shoulder_extend_right
|
||||
self.left_extent += self.shoulder_extend_left
|
||||
self.right_extent += self.shoulder_extend_right
|
||||
|
||||
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
|
||||
self.ditch_points: list[tuple[float, float]] = []
|
||||
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고).
|
||||
* 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬.
|
||||
* 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2).
|
||||
* 교차점 손잡이(370-2) — 쪽마다 설계선 ∩ 원지반 원(키 `toe:left|right` · 늘 보임 · ◀▶) · ◀▶ 가 노견 끝 =
|
||||
* 원지반 자리를 지나면 한 번 섬(`extendStop`) · 사면 키가 절 ↔ 성으로 바뀌면 같은 쪽 손잡이가 이어받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
|
||||
@@ -27,10 +29,13 @@ import {
|
||||
makeRow,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
import { bindCenterShift, centerHit, centerPreviewText } from "./B06_Section_UI_Cross_Center";
|
||||
import { extendStop, toePoints } from "./logic/B06_Section_UI_Cross_Fit";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const DESIGN_CLASS = "b06-chart__design-cross";
|
||||
const HIT_CLASS = "b06-chart__design-hit";
|
||||
/** 교차점 손잡이 원(370-2) — 중심점 판정 원 클래스에 덧붙임(고르기 차례 같음). */
|
||||
const TOE_CLASS = "b06-chart__toe-hit";
|
||||
/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */
|
||||
export const DESIGN_LINE_ATTR = "data-design-line";
|
||||
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
|
||||
@@ -282,7 +287,10 @@ const keysByStation = new Map<string, string[]>();
|
||||
export function pickedLineAt(stationId: string): number | null {
|
||||
if (picked?.stationId !== stationId) return null;
|
||||
const keys = keysByStation.get(stationId);
|
||||
const index = keys ? keys.indexOf(picked.key) : Number(picked.key);
|
||||
let index = keys ? keys.indexOf(picked.key) : Number(picked.key);
|
||||
// 사면이 절 ↔ 성으로 넘어가 키가 바뀌거나 길이 0 으로 사라지면 같은 쪽 교차점 손잡이(370-2)
|
||||
const slope = /^(?:cut|fill)(?:\+s)?:(left|right):/.exec(picked.key);
|
||||
if (index < 0 && keys && slope) index = keys.indexOf(`toe:${slope[1]}`);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
export function setPickedLine(stationId: string, index: number | null): void {
|
||||
@@ -312,7 +320,12 @@ const pointsOf = (element: Element): XY[] => {
|
||||
export interface ShoulderShift {
|
||||
valueOf: (side: CrossSide) => number;
|
||||
set: (side: CrossSide, valueM: number) => void;
|
||||
/** 한 걸음 now → next 사이 노견 끝 = 원지반 자리가 있으면 그 값(370-2 · 없으면 next). */
|
||||
stopAt?: (side: CrossSide, now: number, next: number) => number;
|
||||
}
|
||||
/** 이동값 글 — 0.1 m 눈금이면 한 자리 · 멈춤 자리(370-2)면 두 자리. */
|
||||
const extendText = (valueM: number): string =>
|
||||
valueM.toFixed(Math.abs(valueM * 10 - Math.round(valueM * 10)) < 1e-6 ? 1 : 2);
|
||||
/** 쉬프트 걸음(m). */
|
||||
export const SHIFT_STEP_M = 0.1;
|
||||
|
||||
@@ -345,9 +358,15 @@ function tellBermHost(chainageM: number | null): void {
|
||||
|
||||
/** 카드 → 고름 이동 창구 — 노견 확장(139-6) · 소단 깊이(194-1) 둘 다 카드 버튼과 같은 창구. */
|
||||
export function designLineMoves(
|
||||
section: Pick<CrossSection, "station_id" | "chainage_m" | "design">,
|
||||
section: Pick<CrossSection, "station_id" | "chainage_m" | "design" | "samples">,
|
||||
onDesignChange?: (chainageM: number, change: CrossDesignChange) => void,
|
||||
): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl; center?: number } {
|
||||
): {
|
||||
stationId: string;
|
||||
shift?: ShoulderShift;
|
||||
berm?: BermDepthControl;
|
||||
center?: number;
|
||||
toes?: ReturnType<typeof toePoints>;
|
||||
} {
|
||||
const design = section.design;
|
||||
if (!onDesignChange || !design) return { stationId: section.station_id };
|
||||
const send = (change: Partial<CrossDesignChange>) =>
|
||||
@@ -361,7 +380,9 @@ export function designLineMoves(
|
||||
shift: {
|
||||
valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0,
|
||||
set: (side, valueM) => send({ shoulder_extend: { side, valueM } }),
|
||||
stopAt: (side, now, next) => extendStop(section.samples, design.road_edges, side, now, next),
|
||||
},
|
||||
toes: toePoints(section), // 교차점 손잡이(370-2)
|
||||
berm: {
|
||||
set: (side, valueM) => send({ berm_depth: { side, valueM } }),
|
||||
// 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움
|
||||
@@ -400,6 +421,8 @@ export function attachDesignLinePick(input: {
|
||||
stationId?: string;
|
||||
/** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */
|
||||
center?: number;
|
||||
/** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · 늘 보임 · 370-2). */
|
||||
toes?: ReturnType<typeof toePoints>;
|
||||
}): DesignLinePick {
|
||||
const { layer, design, x, toDisplayY } = input;
|
||||
const x0 = x(0);
|
||||
@@ -429,7 +452,12 @@ export function attachDesignLinePick(input: {
|
||||
: edges.right.elevation_m -
|
||||
((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) /
|
||||
(edges.left.offset_m - edges.right.offset_m);
|
||||
const keys = [...pieceKeys(design, pieces), ...(centerZ === null ? [] : ["center"])];
|
||||
const toeSides = (["left", "right"] as const).filter((at) => input.toes?.[at]);
|
||||
const keys = [
|
||||
...pieceKeys(design, pieces),
|
||||
...(centerZ === null ? [] : ["center"]),
|
||||
...toeSides.map((at) => `toe:${at}`),
|
||||
];
|
||||
if (input.stationId) keysByStation.set(input.stationId, keys);
|
||||
|
||||
// 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1).
|
||||
@@ -448,6 +476,14 @@ export function attachDesignLinePick(input: {
|
||||
// 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`)
|
||||
const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ));
|
||||
if (center) hits.push(center);
|
||||
// 교차점 손잡이(370-2) — 쪽마다 원 · 늘 보임 · 고르면 그 쪽 사면 ◀▶(중심점 원과 같은 고르기 차례)
|
||||
for (const at of toeSides) {
|
||||
const toe = input.toes?.[at] as { offset_m: number; elevation_m: number };
|
||||
const hit = centerHit(x(toe.offset_m), toDisplayY(toe.elevation_m));
|
||||
hit.classList.add(TOE_CLASS);
|
||||
hit.setAttribute("r", "5");
|
||||
hits.push(hit);
|
||||
}
|
||||
hits.forEach((hit, index) => {
|
||||
hit.addEventListener("click", (event) => {
|
||||
event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음
|
||||
@@ -486,7 +522,11 @@ export function attachDesignLinePick(input: {
|
||||
: null;
|
||||
const step = (dir: -1 | 1) => () => {
|
||||
if (axis === "center") return centerShift?.step(dir);
|
||||
if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx));
|
||||
if (!side || !shift) return;
|
||||
// 노견 끝 = 원지반(절도 성도 아닌 점)을 지나면 그 자리에 한 번 섬(370-2)
|
||||
const now = shift.valueOf(side);
|
||||
const next = shiftedExtend(now, side, dir, kx);
|
||||
shift.set(side, shift.stopAt?.(side, now, next) ?? next);
|
||||
};
|
||||
// 툴팁 = 고른 것의 실제 동작(364-7 · 사면 = 노견째 이동 · 중심점 = 그 측점 노선)
|
||||
const stepTitle = (screen: "왼쪽" | "오른쪽"): string =>
|
||||
@@ -546,22 +586,29 @@ export function attachDesignLinePick(input: {
|
||||
label,
|
||||
panel: shell.root,
|
||||
setActive: (index) => {
|
||||
const onCenter = !!centerShift && index === pieces.length;
|
||||
const piece = index === null ? undefined : pieces[index];
|
||||
hits.forEach((hit, i) =>
|
||||
hit.classList.toggle("is-active", (!!piece || onCenter) && i === index),
|
||||
);
|
||||
label.hidden = !piece && !onCenter;
|
||||
active = piece || onCenter ? index : null;
|
||||
side = piece ? pieceZone(design, piece).side : null;
|
||||
const key = index === null ? undefined : keys[index];
|
||||
const onCenter = !!centerShift && key === "center";
|
||||
const toeSide = key?.startsWith("toe:") ? (key.slice(4) as CrossSide) : null;
|
||||
const piece = index === null || index >= pieces.length ? undefined : pieces[index];
|
||||
const chosen = !!piece || onCenter || !!toeSide;
|
||||
hits.forEach((hit, i) => hit.classList.toggle("is-active", chosen && i === index));
|
||||
label.hidden = !chosen;
|
||||
active = chosen ? index : null;
|
||||
side = piece ? pieceZone(design, piece).side : toeSide;
|
||||
axis = onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x";
|
||||
const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0;
|
||||
const sideName = side === "left" ? "(좌)" : "(우)";
|
||||
const moved = extend > 0 ? ` · 이동 +${extendText(extend)}m` : "";
|
||||
const toe = toeSide ? input.toes?.[toeSide] : null;
|
||||
label.textContent = onCenter
|
||||
? "중심점"
|
||||
: piece
|
||||
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
|
||||
: "";
|
||||
const open = onCenter || !!(piece && (axis === "depth" ? berm : shift));
|
||||
: toe
|
||||
? `교차점 ${sideName} · 노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}`
|
||||
: piece
|
||||
? pieceLabel(design, piece) + moved
|
||||
: "";
|
||||
const open =
|
||||
onCenter || (!!toeSide && !!shift) || !!(piece && (axis === "depth" ? berm : shift));
|
||||
shell.root.classList.toggle("is-hidden", !open);
|
||||
// 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단)
|
||||
// 중심점 = 이동 줄 ◀▶(노선 좌우 · ↺ 없음) · ▲▼ 는 종단도로(356-3)
|
||||
@@ -573,15 +620,16 @@ export function attachDesignLinePick(input: {
|
||||
[toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")];
|
||||
lift.row.classList.toggle("is-hidden", axis !== "depth");
|
||||
lift.controls.classList.toggle("is-hidden", axis !== "depth");
|
||||
const sideName = side === "left" ? "(좌)" : "(우)";
|
||||
shell.title.textContent =
|
||||
axis === "center"
|
||||
? "중심점"
|
||||
: axis === "y"
|
||||
? "노면 높이"
|
||||
: axis === "depth"
|
||||
? `소단 높이 ${sideName}`
|
||||
: `사면 이동 ${sideName}`;
|
||||
: toeSide
|
||||
? `교차점 이동 ${sideName}`
|
||||
: axis === "y"
|
||||
? "노면 높이"
|
||||
: axis === "depth"
|
||||
? `소단 높이 ${sideName}`
|
||||
: `사면 이동 ${sideName}`;
|
||||
readout.textContent =
|
||||
axis === "center"
|
||||
? centerPreviewText(0)
|
||||
@@ -589,7 +637,7 @@ export function attachDesignLinePick(input: {
|
||||
? "방향키 ▲▼ = 종단 계획고(종단도 함께)"
|
||||
: axis === "depth" && side
|
||||
? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m`
|
||||
: `이동 ${extend.toFixed(1)}m`;
|
||||
: `이동 ${extendText(extend)}m`;
|
||||
berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2)
|
||||
arrows.detach();
|
||||
document.removeEventListener("keydown", blockOffAxis);
|
||||
|
||||
@@ -193,6 +193,18 @@
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
/* 교차점 손잡이(370-2) — 사면 ∩ 원지반 작은 원 · 늘 보임 · 고르면 채움. */
|
||||
.b06-chart__toe-hit {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b06-chart__toe-hit.is-active {
|
||||
fill: var(--color-royal-amethyst);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
|
||||
.b06-chart__carriageway-tick {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
*
|
||||
* 설계선이 지반과 만나기 전에 끝나면(계산 반폭 밖) **추세로 외삽**한다 — 끝단의 사면
|
||||
* 기울기와 지반 기울기 차이로 만나는 자리를 추정한다.
|
||||
*
|
||||
* 교차점 손잡이 자리(`toePoints`) · 사면 이동 멈춤(`extendStop`)도 여기(370-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossSection } from "../B06_Section_Api_Fetch";
|
||||
@@ -56,6 +58,60 @@ export function toeOffsets(section: CrossSection): { left: number | null; right:
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 교차점 손잡이 자리(370-2) — `toeOffsets` 자리 · 표고 = 원지반. 못 만난 쪽은 null. */
|
||||
export function toePoints(
|
||||
section: Pick<CrossSection, "design" | "samples">,
|
||||
): Record<"left" | "right", { offset_m: number; elevation_m: number } | null> {
|
||||
if (!section.samples?.length) return { left: null, right: null };
|
||||
const toes = toeOffsets(section as CrossSection);
|
||||
const groundAt = groundInterpolator(section.samples);
|
||||
const at = (offset: number | null) =>
|
||||
offset === null || !groundAt ? null : { offset_m: offset, elevation_m: groundAt(offset) };
|
||||
return { left: at(toes.left), right: at(toes.right) };
|
||||
}
|
||||
|
||||
/** 멈춤 탐색 간격(m) — 사면 이동 걸음(0.1 m)보다 촘촘하게. */
|
||||
const STOP_SCAN_M = 0.01;
|
||||
|
||||
/**
|
||||
* 사면 이동 멈춤(370-2) — 그 쪽 노견 확장 `now` → `next` 사이에서 옮긴 노견 끝이 원지반과 딱 만나는
|
||||
* (절토도 성토도 아닌) 확장값. 원지반 − 노면 평면(`road_edges` 두 점을 이은 선) 부호가 바뀌는 자리를
|
||||
* 보간 · 못 찾거나 지금 그 자리면 `next` 그대로. 0.001 m 반올림.
|
||||
*/
|
||||
export function extendStop(
|
||||
samples: CrossSection["samples"],
|
||||
edges: CrossDesign["road_edges"] | undefined,
|
||||
side: "left" | "right",
|
||||
now: number,
|
||||
next: number,
|
||||
): number {
|
||||
const groundAt = groundInterpolator(samples ?? []);
|
||||
if (!groundAt || !edges || edges.left.offset_m === edges.right.offset_m) return next;
|
||||
const { left, right } = edges;
|
||||
const plane = (o: number) =>
|
||||
right.elevation_m +
|
||||
((left.elevation_m - right.elevation_m) * (o - right.offset_m)) /
|
||||
(left.offset_m - right.offset_m);
|
||||
const sign = side === "left" ? 1 : -1;
|
||||
const base = Math.abs(edges[side].offset_m) - now; // 확장 0 노견 끝 거리
|
||||
const gapAt = (extend: number) => {
|
||||
const o = sign * (base + extend);
|
||||
return groundAt(o) - plane(o);
|
||||
};
|
||||
let previous = gapAt(now);
|
||||
if (Math.abs(previous) < 1e-4) return next;
|
||||
const steps = Math.max(1, Math.ceil(Math.abs(next - now) / STOP_SCAN_M));
|
||||
for (let i = 1; i <= steps; i += 1) {
|
||||
const a = now + ((next - now) * (i - 1)) / steps;
|
||||
const b = now + ((next - now) * i) / steps;
|
||||
const gap = gapAt(b);
|
||||
if (gap === 0 || Math.sign(gap) !== Math.sign(previous))
|
||||
return Math.round((a + ((b - a) * previous) / (previous - gap)) * 1000) / 1000;
|
||||
previous = gap;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
|
||||
*
|
||||
|
||||
@@ -180,6 +180,16 @@ export class SectionGeometry {
|
||||
this.slopePerOffset = lowLeft ? -slope : slope;
|
||||
}
|
||||
|
||||
// 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 노견(폭 고정) · 측구 · 사면이 같이
|
||||
// 바깥으로 · 늘어난 폭은 차도 · 단면유형 · 측구 판정은 **옮긴 뒤** 노견에서(아래 · 370-1 — 마루를
|
||||
// 넘으면 그 쪽 사면 기울기로 원지반까지). ⚠ 파이썬 짝 `_SectionGeometry`.
|
||||
this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0);
|
||||
this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0);
|
||||
this.halfRoadLeft += this.shoulderExtendLeft;
|
||||
this.halfRoadRight += this.shoulderExtendRight;
|
||||
this.leftExtent += this.shoulderExtendLeft;
|
||||
this.rightExtent += this.shoulderExtendRight;
|
||||
|
||||
// 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토.
|
||||
const groundAt = params.groundAt;
|
||||
if (groundAt !== null) {
|
||||
@@ -226,15 +236,6 @@ export class SectionGeometry {
|
||||
// 물넘이포장 · 맹암거 자리(286-2 · 288-1) — 측구가 안 섬 · 선택은 그대로(파이썬 짝)
|
||||
if (params.tilted) this.hasDitch = false;
|
||||
|
||||
// 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
|
||||
// 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`.
|
||||
this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0);
|
||||
this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0);
|
||||
this.halfRoadLeft += this.shoulderExtendLeft;
|
||||
this.halfRoadRight += this.shoulderExtendRight;
|
||||
this.leftExtent += this.shoulderExtendLeft;
|
||||
this.rightExtent += this.shoulderExtendRight;
|
||||
|
||||
// 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
|
||||
const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
|
||||
const outward = params.ditchSide === "left" ? 1 : -1;
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/* PLAN 370-2 단위검증 헬퍼 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기.
|
||||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
|
||||
* 입력 = argv[2] 표준단면 JSON(파이썬 config) · 마루 지반(우측 −4 m 너머 내리막). 짝 = `test_370_2_toe_stop.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
const STUBS = {
|
||||
"@ui/ui_template_toast": { showToast: () => undefined },
|
||||
"@ui/ui_template_elements": { showToast: () => undefined },
|
||||
};
|
||||
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = String(tag);
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.listeners = {};
|
||||
this.hidden = false;
|
||||
this.title = "";
|
||||
this._text = "";
|
||||
this._classes = new Set();
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...names) => names.forEach((n) => self._classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
|
||||
toggle: (name, force) => {
|
||||
const on = force === undefined ? !self._classes.has(name) : !!force;
|
||||
on ? self._classes.add(name) : self._classes.delete(name);
|
||||
return on;
|
||||
},
|
||||
contains: (name) => self._classes.has(name),
|
||||
};
|
||||
this.style = { setProperty: () => {} };
|
||||
}
|
||||
set className(value) {
|
||||
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this._classes].join(" ");
|
||||
}
|
||||
set textContent(value) {
|
||||
this._text = String(value);
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
get childElementCount() {
|
||||
return this.children.length;
|
||||
}
|
||||
get isConnected() {
|
||||
return true;
|
||||
}
|
||||
_detach(kid) {
|
||||
if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid);
|
||||
kid.parent = this;
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => {
|
||||
if (typeof k === "string") return;
|
||||
this._detach(k);
|
||||
this.children.push(k);
|
||||
});
|
||||
}
|
||||
prepend(...kids) {
|
||||
kids.reverse().forEach((k) => {
|
||||
this._detach(k);
|
||||
this.children.unshift(k);
|
||||
});
|
||||
}
|
||||
appendChild(kid) {
|
||||
this.append(kid);
|
||||
return kid;
|
||||
}
|
||||
insertBefore(kid, ref) {
|
||||
this._detach(kid);
|
||||
const at = ref ? this.children.indexOf(ref) : -1;
|
||||
if (at < 0) this.children.push(kid);
|
||||
else this.children.splice(at, 0, kid);
|
||||
return kid;
|
||||
}
|
||||
remove() {
|
||||
if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this);
|
||||
}
|
||||
querySelector() {
|
||||
return null;
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attrs[name] = String(value);
|
||||
if (name === "class") this.className = value;
|
||||
}
|
||||
getAttribute(name) {
|
||||
return name === "class" ? this.className : (this.attrs[name] ?? null);
|
||||
}
|
||||
hasAttribute(name) {
|
||||
return name in this.attrs;
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
(this.listeners[type] ??= []).push(fn);
|
||||
}
|
||||
click() {
|
||||
const event = { stopPropagation: () => {}, preventDefault: () => {} };
|
||||
(this.listeners.click ?? []).forEach((fn) => fn(event));
|
||||
}
|
||||
}
|
||||
globalThis.document = {
|
||||
createElement: (tag) => new FakeNode(tag),
|
||||
createElementNS: (_ns, tag) => new FakeNode(tag),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
globalThis.window = { setTimeout, clearTimeout, addEventListener: () => {} };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
if (full.endsWith(".json")) {
|
||||
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
cache.set(full, data);
|
||||
return data;
|
||||
}
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.endsWith(".css")) return {};
|
||||
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
||||
for (const [prefix, dir] of Object.entries(alias))
|
||||
if (request.startsWith(prefix))
|
||||
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
const resolve = (base) =>
|
||||
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
||||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||||
) ?? `${base}.ts`;
|
||||
|
||||
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
|
||||
const pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick"));
|
||||
const fit = loadTs(B06("logic/B06_Section_UI_Cross_Fit"));
|
||||
const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design"));
|
||||
const { computeCrossDesign } = loadTs(
|
||||
path.join(ROOT, "common_util", "common_util_cross_design.ts"),
|
||||
);
|
||||
const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const out = {};
|
||||
|
||||
// 마루 지반 — 우측 −4 m 까지 평지(노면보다 높음 = 절토) · 그 너머 1:0.6 내리막
|
||||
const samples = Array.from({ length: 49 }, (_, i) => {
|
||||
const o = -12 + i * 0.5;
|
||||
return { offset_m: o, elevation_m: 100.3 - 0.6 * Math.max(0, -o - 4), valid: true };
|
||||
});
|
||||
const groundAt = (o) => 100.3 - 0.6 * Math.max(0, -o - 4);
|
||||
const designOf = (extendRight) => {
|
||||
const result = computeCrossDesign(samples, 100, {
|
||||
standard,
|
||||
groundType: "soil",
|
||||
sectionMode: "right_cut",
|
||||
ditchSide: "right",
|
||||
shoulderExtendRightM: extendRight,
|
||||
});
|
||||
// 화면 design 칸 이름(저장 · 응답과 같음)
|
||||
return { ...result, shoulder_extend_right_m: extendRight, shoulder_extend_left_m: 0 };
|
||||
};
|
||||
const section = (extendRight) => ({
|
||||
station_id: "S1",
|
||||
chainage_m: 20,
|
||||
samples,
|
||||
design: designOf(extendRight),
|
||||
});
|
||||
|
||||
// ① 멈춤 — 우측 노견 끝이 원지반을 지나는 한 걸음 사이 → 그 자리
|
||||
const gapAt = (design, e) => {
|
||||
const { left, right } = design.road_edges;
|
||||
const o = -(Math.abs(right.offset_m) - (design.shoulder_extend_right_m ?? 0) + e);
|
||||
const plane =
|
||||
right.elevation_m +
|
||||
((left.elevation_m - right.elevation_m) * (o - right.offset_m)) /
|
||||
(left.offset_m - right.offset_m);
|
||||
return groundAt(o) - plane;
|
||||
};
|
||||
const base = designOf(0);
|
||||
let crossing = null;
|
||||
for (let e = 0; e < 6; e = Math.round((e + 0.1) * 10) / 10) {
|
||||
if (Math.sign(gapAt(base, e)) !== Math.sign(gapAt(base, e + 0.1))) {
|
||||
crossing = [e, Math.round((e + 0.1) * 10) / 10];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const stop = fit.extendStop(
|
||||
samples,
|
||||
designOf(crossing[0]).road_edges,
|
||||
"right",
|
||||
crossing[0],
|
||||
crossing[1],
|
||||
);
|
||||
out.stop = {
|
||||
crossing,
|
||||
value: stop,
|
||||
gap: Math.abs(gapAt(base, stop)),
|
||||
noCross: fit.extendStop(samples, base.road_edges, "right", 0, 0.1),
|
||||
// 멈춘 자리에서 다음 걸음은 그대로 감(다시 안 섬)
|
||||
fromStop: fit.extendStop(
|
||||
samples,
|
||||
designOf(stop).road_edges,
|
||||
"right",
|
||||
stop,
|
||||
Math.round((stop + 0.1) * 10) / 10,
|
||||
),
|
||||
leftNoGround: fit.extendStop([], base.road_edges, "left", 0, 0.1),
|
||||
};
|
||||
|
||||
// ② 교차점 손잡이 — 쪽마다 원 · 늘 보임 · 키 toe:left|right · 고르면 ◀▶ 가 멈춤 자리로 보냄
|
||||
const x = (o) => 300 - o * 20;
|
||||
const y = (e) => 400 - (e - 100) * 20;
|
||||
const attach = (sec, changes) => {
|
||||
const layer = new FakeNode("g");
|
||||
const from = layer.childElementCount;
|
||||
appendCrossDesignOverlay(layer, sec.design, x, y, sec.samples, undefined);
|
||||
layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, ""));
|
||||
const picks = [];
|
||||
const handle = pick.attachDesignLinePick({
|
||||
layer,
|
||||
design: sec.design,
|
||||
x,
|
||||
toDisplayY: y,
|
||||
onPick: (i) => picks.push(i),
|
||||
...pick.designLineMoves(sec, (_chainage, change) => changes.push(change)),
|
||||
});
|
||||
return { layer, handle, picks };
|
||||
};
|
||||
const changes = [];
|
||||
const near = section(crossing[0]);
|
||||
const { layer, handle, picks } = attach(near, changes);
|
||||
const toes = layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
|
||||
const keys = pick.designLineMoves(near, () => undefined).toes;
|
||||
out.toe = {
|
||||
count: toes.length,
|
||||
centerClass: toes.every((t) => t.classList.contains("b06-chart__center-hit")),
|
||||
sides: ["left", "right"].filter((s) => keys[s]),
|
||||
rightOnGround:
|
||||
keys.right && Math.abs(keys.right.elevation_m - groundAt(keys.right.offset_m)) < 1e-9,
|
||||
rightCx: toes.length === 2 ? Number(toes[1].getAttribute("cx")) : null,
|
||||
rightX: keys.right ? x(keys.right.offset_m) : null,
|
||||
};
|
||||
toes[1].click();
|
||||
handle.setActive(picks[0]);
|
||||
const right = handle.panel.children.find((k) =>
|
||||
k.classList?.contains("b06-structure-panel__struct"),
|
||||
);
|
||||
const buttons = right.children[1].children;
|
||||
const rightButton = x(-1) > x(0) ? buttons[2] : buttons[0]; // 화면 → 바깥(우 = −offset)
|
||||
rightButton.click();
|
||||
out.toePick = {
|
||||
picked: picks,
|
||||
active: toes[1].classList.contains("is-active"),
|
||||
label: handle.label.textContent,
|
||||
title: handle.panel.children[0].children[0].textContent,
|
||||
panelOpen: !handle.panel.classList.contains("is-hidden"),
|
||||
sent: changes.map((c) => c.shoulder_extend),
|
||||
};
|
||||
|
||||
// ③ 고름 이어받기 — 우 절토 사면을 고른 뒤 마루 너머로 옮기면(키 cut → fill) 같은 쪽 손잡이
|
||||
const cutIndex = handle.pieces.findIndex(
|
||||
(p) =>
|
||||
pick.pieceZone(near.design, p).zone === "cut" &&
|
||||
pick.pieceZone(near.design, p).side === "right",
|
||||
);
|
||||
pick.setPickedLine("S1", null);
|
||||
attach(near, []); // 키 적음
|
||||
pick.setPickedLine("S1", cutIndex);
|
||||
const before = pick.pickedLineAt("S1");
|
||||
const far = section(crossing[1] + 1.0);
|
||||
const after = attach(far, []);
|
||||
const afterIndex = pick.pickedLineAt("S1");
|
||||
out.inherit = {
|
||||
cutIndex,
|
||||
before,
|
||||
farMode: far.design.section_mode,
|
||||
afterIndex,
|
||||
};
|
||||
after.handle.setActive(afterIndex);
|
||||
out.inherit.label = after.handle.label.textContent;
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,79 @@
|
||||
"""PLAN 370-2 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기(요구 240-14).
|
||||
|
||||
짝: `B06_Section/logic/B06_Section_UI_Cross_Fit.ts`(`extendStop` · `toePoints`) ·
|
||||
`B06_Section/B06_Section_UI_Cross_Design_Pick.ts`(손잡이 · 키 이어받기). 실제 TS 를
|
||||
`helper_370_2_toe_stop.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out(tmp_path_factory) -> dict:
|
||||
standard = tmp_path_factory.mktemp("std") / "standard.json"
|
||||
standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_370_2_toe_stop.cjs"), str(standard)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_노견_끝이_원지반을_지나면_그_자리에_한_번_섬(out) -> None:
|
||||
stop = out["stop"]
|
||||
low, high = stop["crossing"]
|
||||
assert low < stop["value"] < high
|
||||
assert stop["gap"] < 2e-3 # 노견 끝 = 원지반
|
||||
assert stop["noCross"] == 0.1 # 안 지나면 걸음 그대로
|
||||
assert abs(stop["fromStop"] - round(stop["value"] + 0.1, 1)) < 1e-9 # 멈춘 뒤엔 다시 안 섬
|
||||
assert stop["leftNoGround"] == 0.1 # 지반 없으면 그대로
|
||||
|
||||
|
||||
def test_교차점_손잡이_쪽마다_늘_보임(out) -> None:
|
||||
toe = out["toe"]
|
||||
assert toe["count"] == len(toe["sides"]) == 2
|
||||
assert toe["centerClass"] is True # 중심점 원과 같은 고르기 차례
|
||||
assert toe["rightOnGround"] is True
|
||||
assert abs(toe["rightCx"] - toe["rightX"]) < 1e-9
|
||||
|
||||
|
||||
def test_손잡이를_고르면_그_쪽_사면_좌우_이동(out) -> None:
|
||||
got = out["toePick"]
|
||||
assert got["active"] is True and got["panelOpen"] is True
|
||||
assert got["label"].startswith("교차점 (우) · 노선에서 ")
|
||||
assert got["title"] == "교차점 이동 (우)"
|
||||
assert len(got["sent"]) == 1 and got["sent"][0]["side"] == "right"
|
||||
low, high = out["stop"]["crossing"]
|
||||
assert abs(got["sent"][0]["valueM"] - out["stop"]["value"]) < 1e-9 # ◀▶ 도 멈춤 자리
|
||||
assert low < got["sent"][0]["valueM"] < high
|
||||
|
||||
|
||||
def test_절에서_성으로_넘어가도_고름_유지(out) -> None:
|
||||
inherit = out["inherit"]
|
||||
assert inherit["cutIndex"] >= 0 and inherit["before"] == inherit["cutIndex"]
|
||||
assert inherit["farMode"] == "left_cut" # 우 = 성토로 넘어감
|
||||
assert inherit["afterIndex"] is not None
|
||||
assert inherit["label"].startswith("교차점 (우)")
|
||||
@@ -61,6 +61,22 @@ def _dip_ground() -> list[dict]:
|
||||
return samples
|
||||
|
||||
|
||||
def _ridge_ground() -> list[dict]:
|
||||
"""우측 −4 m 까지 노면보다 높은 평지(절토) · 그 너머 급히 내려가는 마루(370-1)."""
|
||||
return [
|
||||
{"offset_m": o, "elevation_m": round(100.3 - 0.6 * max(0.0, -o - 4.0), 4), "valid": True}
|
||||
for o in (-12.0 + i * 0.5 for i in range(49))
|
||||
]
|
||||
|
||||
|
||||
def _rise_ground() -> list[dict]:
|
||||
"""좌측 +3 m 까지 노면보다 낮은 평지(성토) · 그 너머 오르막(370-1)."""
|
||||
return [
|
||||
{"offset_m": o, "elevation_m": round(99.6 + 0.4 * max(0.0, o - 3.0), 4), "valid": True}
|
||||
for o in (-12.0 + i * 0.5 for i in range(49))
|
||||
]
|
||||
|
||||
|
||||
# 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교
|
||||
# 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간.
|
||||
CASES = [
|
||||
@@ -214,8 +230,8 @@ CASES = [
|
||||
},
|
||||
},
|
||||
{
|
||||
# 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮기기 전 노견에서
|
||||
"name": "노견 확장 — 판정은 옮기기 전 노견",
|
||||
# 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮긴 노견에서(370-1)
|
||||
"name": "노견 확장 — 판정은 옮긴 노견",
|
||||
"samples": _ground(-0.05),
|
||||
"design_elevation_m": 100.0,
|
||||
"options": {
|
||||
@@ -226,6 +242,30 @@ CASES = [
|
||||
"shoulder_extend_left_m": 2.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
# 370-1 — 우 절토 마루를 넘겨 옮김 = 그 쪽 성토 1:n 으로 원지반까지(원지반 직선 아님)
|
||||
"name": "마루 너머 = 성토 1:n",
|
||||
"samples": _ridge_ground(),
|
||||
"design_elevation_m": 100.0,
|
||||
"options": {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "right_cut",
|
||||
"ditch_side": "right",
|
||||
"shoulder_extend_right_m": 3.0,
|
||||
},
|
||||
},
|
||||
{
|
||||
# 370-1 — 좌 성토를 오르막으로 옮김 = 그 쪽 절토(측구 자동 판정 따름)
|
||||
"name": "성 → 절 넘어감",
|
||||
"samples": _rise_ground(),
|
||||
"design_elevation_m": 100.0,
|
||||
"options": {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "left_cut",
|
||||
"ditch_side": "left",
|
||||
"shoulder_extend_left_m": 2.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
# 151-2 — 곡선부 확폭과 사면 이동이 같은 쪽에 겹침(확폭 출력엔 이동 몫 안 섞임)
|
||||
"name": "확폭 좌 + 사면 이동 좌 0.4",
|
||||
@@ -490,3 +530,33 @@ def test_사면_이동은_노견째_평행이동_늘어난_폭은_차도() -> No
|
||||
both = by_name["확폭 좌 + 사면 이동 좌 0.4"]
|
||||
assert both["widening_left_m"] == curve["widening_left_m"] > 0
|
||||
assert abs(both["carriageway_width_m"] - (curve["carriageway_width_m"] + 0.4)) < 1e-9
|
||||
|
||||
|
||||
def _outer_slope(result: dict, side: str, skip: float = 0.0) -> float:
|
||||
"""노면 끝에서 `skip`(측구 폭) 바깥 설계선 한 마디의 기울기(dz / 바깥 거리)."""
|
||||
sign = 1.0 if side == "left" else -1.0
|
||||
start_offset = result["road_edges"][side]["offset_m"] + sign * skip
|
||||
line = sorted(result["design_line"], key=lambda p: sign * p["offset_m"])
|
||||
index = next(i for i, p in enumerate(line) if abs(p["offset_m"] - start_offset) < 1e-6)
|
||||
start, nxt = line[index], line[index + 1]
|
||||
return (nxt["elevation_m"] - start["elevation_m"]) / abs(nxt["offset_m"] - start["offset_m"])
|
||||
|
||||
|
||||
def test_절성_넘어가면_그_쪽_사면_기울기로_원지반까지() -> None:
|
||||
"""370-1 — 옮긴 노견에서 절/성 판정 · 마루 너머 = 성토 1:n · 성 → 절 = 절토 1:n + 측구 자동."""
|
||||
by_name = dict(zip((case["name"] for case in CASES), _python_results(), strict=True))
|
||||
soil = STANDARD_CROSS_SECTION["soil"]
|
||||
ridge = by_name["마루 너머 = 성토 1:n"]
|
||||
assert ridge["section_mode"] == "left_cut" # 우 = 성토
|
||||
assert ridge["ditch_enabled"] is False # 우 측구 자동 판정 따라 빠짐
|
||||
assert ridge["fill_area_m2"] > 0
|
||||
assert abs(_outer_slope(ridge, "right") + 1.0 / soil["fill_slope_ratio"]) < 1e-3
|
||||
rise = by_name["성 → 절 넘어감"]
|
||||
assert rise["section_mode"] == "left_cut" # 좌 = 절토
|
||||
assert rise["ditch_enabled"] is True and rise["ditch_side"] == "left"
|
||||
assert (
|
||||
abs(
|
||||
_outer_slope(rise, "left", soil["ditch"]["top_width_m"]) - 1.0 / soil["cut_slope_ratio"]
|
||||
)
|
||||
< 1e-3
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user