Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-08 12:39:55 +09:00
18 changed files with 611 additions and 94 deletions
+2 -2
View File
@@ -110,7 +110,7 @@ export function createSpreadsheet(
): SpreadsheetHandle {
const readOnly = !!opts.readOnly;
const noTabs = opts.tabs === false;
const bare = readOnly || !!opts.preview; // 도구 모음 · 메뉴 없음
const bare = readOnly || !!opts.preview; // 도구 모음 없음 · 우클릭은 「복사」 한 줄
const root = el("div", {
className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}${opts.preview ? " ss--preview" : ""}`,
});
@@ -142,7 +142,7 @@ export function createSpreadsheet(
ctx.editor = ed;
const toolbar: PartHandle | null = bare ? null : mountToolbar(ctx);
const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx);
const menu: PartHandle | null = bare ? null : attachMenu(ctx);
const menu: PartHandle = attachMenu(ctx); // 읽기 전용 · 미리보기는 「복사」 한 줄(81-3)
const clip = attachClipboard(ctx);
const status = createStatusBar(ctx);
const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p);
+37 -17
View File
@@ -12,10 +12,21 @@
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createButton, el } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import { confirmMerge, dialogKeys } from "./spreadsheet_actions";
import { deleteComment, getComment, setComment } from "./spreadsheet_comments";
import {
cellInsertItems,
cellMoreItems,
colInsertItems,
colMoreItems,
copyCut,
copyOnlyItems,
pasteItems,
rowInsertItems,
rowMoreItems,
} from "./spreadsheet_menu_items";
import { st } from "./spreadsheet_text";
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
@@ -28,7 +39,8 @@ function within(range: CellRange, r: number, c: number): boolean {
/** `ctx.root` 의 contextmenu 를 받음(root null) */
export function attachMenu(ctx: SpreadsheetContext): PartHandle {
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
// 읽기 전용 · 미리보기 = 「복사」 한 줄(81-3)
const bare = ctx.readOnly || ctx.root.classList.contains("ss--preview");
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
@@ -39,7 +51,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
}
function cutCopy(kind: "cut" | "copy"): void {
document.execCommand(kind);
copyCut(ctx, kind);
}
/** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는
@@ -79,7 +91,10 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
...pasteItems(ctx),
"-",
...cellInsertItems(ctx, range),
"-",
[
st("MenuClearContent"),
() => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }),
@@ -107,6 +122,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
...(note
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
: []),
...cellMoreItems(ctx, range),
...(ctx.cellMenuExtra?.(r, c) ?? []),
];
}
@@ -117,11 +133,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertRow"),
() => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }),
],
...pasteItems(ctx),
"-",
...rowInsertItems(ctx, at, 수),
[
st("MenuDeleteRow"),
() => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }),
@@ -148,6 +162,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
숨김: false,
}),
],
...rowMoreItems(ctx, rows),
];
}
@@ -157,11 +172,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertCol"),
() => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }),
],
...pasteItems(ctx),
"-",
...colInsertItems(ctx, at, 수),
[
st("MenuDeleteCol"),
() => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }),
@@ -188,6 +201,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
숨김: false,
}),
],
...colMoreItems(ctx, cols),
];
}
@@ -217,7 +231,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
활성: { r: hit.r, c: 0 },
기준: { r: hit.r, c: 0 },
});
menu.open(x, y, rowMenuItems(rows));
menu.open(x, y, bare ? copyOnlyItems(ctx) : rowMenuItems(rows));
return;
}
@@ -235,7 +249,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
활성: { r: 0, c: hit.c },
기준: { r: 0, c: hit.c },
});
menu.open(x, y, colMenuItems(cols));
menu.open(x, y, bare ? copyOnlyItems(ctx) : colMenuItems(cols));
return;
}
@@ -250,7 +264,13 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
});
}
const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c);
menu.open(x, y, cellMenuItems(active, hit.r, hit.c));
menu.open(
x,
y,
bare
? [...copyOnlyItems(ctx), ...(ctx.cellMenuExtra?.(hit.r, hit.c) ?? [])]
: cellMenuItems(active, hit.r, hit.c),
);
}
}
@@ -211,7 +211,7 @@ export function fordSilhouettes(section: CrossSection): StructureSilhouette[] {
points.push(floorOuter);
patchPoints.push(floorOuter);
const run: OffsetPoint[] = [floorOuter];
for (const cutPoint of [side.cutLine.from, side.cutLine.to]) {
for (const cutPoint of side.cutLine) {
const at = { offset_m: cutPoint.offset, elevation_m: cutPoint.elevation };
points.push(at);
patchPoints.push(at);
@@ -347,8 +347,8 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[]
}
// 집수정이 원지반에 박히면 절토선까지 — 그만큼 원지반을 걷어낸다.
if (layout.basin.cutLine) {
push(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]);
addRun(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]);
push(inletSide, layout.basin.cutLine);
addRun(inletSide, layout.basin.cutLine);
basinCut = true;
}
spans[inletSide] = basinSpanOf(section);
+1 -1
View File
@@ -148,7 +148,7 @@ export function appendBoxOverlay(
}));
if (side.cutLine)
lines.push({
points: [side.cutLine.from, side.cutLine.to],
points: side.cutLine,
text: `${mark}측 끝 절토선(계획선)`,
});
for (const line of lines) {
+4 -8
View File
@@ -26,6 +26,7 @@ import type {
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { structureCutPoints, structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut";
/** 구체 한쪽 끝의 조작값 — 좌·우를 따로 잡는다(2026-08-25 사용자). */
export interface BoxSideAdjust {
@@ -135,7 +136,8 @@ export function computeBoxLayout(
// 본체는 늘 수평(79-7) — 좌 · 우 표고 조정이 달라도(옛 저장) 평균 하나로 섬.
const riseM = (adjust.left.riseM + adjust.right.riseM) / 2;
const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0);
const cutSlopeRatio = section.design?.cut_slope_ratio ?? 1.0;
// 끝 절토 = 본선 절토 사면과 같은 복합경사(91-4)
const cutSpec = structureCutSpec(section.design);
const sideOf = (role: BoxSideRole): BoxSideLayout => {
const outward = role === "left" ? 1 : -1;
@@ -172,13 +174,7 @@ export function computeBoxLayout(
let cutLine: BasinLayout["cutLine"] = null;
let fillSegments: OutletFillSegment[] = [];
if (groundAt(end.offset) > end.elevation + 0.05) {
for (let h = 0.05; h <= 20; h += 0.05) {
const probe = end.offset + outward * cutSlopeRatio * h;
if (groundAt(probe) <= end.elevation + h) {
cutLine = { from: end, to: { offset: probe, elevation: end.elevation + h } };
break;
}
}
cutLine = structureCutPoints(end, outward, cutSpec, groundAt);
} else if (groundAt(end.offset) < end.elevation - 0.05) {
fillSegments = buildExtrasAt(end, {
startBottomElevation: bottomElevation,
+6 -5
View File
@@ -235,11 +235,12 @@ export function appendCulvertOverlay(
layer.append(shape);
}
if (basin.cutLine) {
const cut = document.createElementNS(SVG_NS, "line");
cut.setAttribute("x1", String(x(basin.cutLine.from.offset)));
cut.setAttribute("y1", String(toDisplayY(basin.cutLine.from.elevation)));
cut.setAttribute("x2", String(x(basin.cutLine.to.offset)));
cut.setAttribute("y2", String(toDisplayY(basin.cutLine.to.elevation)));
// 점열(암 무릎 · 소단 · 91-4)
const cut = document.createElementNS(SVG_NS, "polyline");
cut.setAttribute(
"points",
basin.cutLine.map((point) => `${x(point.offset)},${toDisplayY(point.elevation)}`).join(" "),
);
// 절토선은 **공사 계획선**이다 — 설계선과 같은 보라 실선으로 그린다
// (2026-08-20 사용자 ②).
cut.setAttribute("class", "b06-chart__design-cross");
@@ -20,7 +20,7 @@ import {
REVET_FREEBOARD_M,
REVET_LEAN_RATIO,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { CulvertSideSpec } from "./B06_Section_Api_Fetch";
import type { CrossDesign, CulvertSideSpec } from "./B06_Section_Api_Fetch";
import {
minShoulderWallOffset,
slopeLengthAlong,
@@ -34,6 +34,7 @@ import type {
OffsetPoint,
InletStructureChoice,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { structureCutPoints, structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut";
/** 자동(기본) 집수정 모양(54-36) — 좌측 폼 「(기본)」 표시도 이 값. */
export const DEFAULT_BASIN_SHAPE: BasinShape = "L";
@@ -140,8 +141,10 @@ export interface BasinBuildInput {
/** 이 측의 노견 끝 점 — 벽 상단 접점·I형 높이 성장 기준. */
edge: { offset_m: number; elevation_m: number };
groundAt: (offset: number) => number;
/** 표준단면 절토 경사(1:n) — 절토 계획선용. */
cutSlopeRatio: number;
/** 표준단면 절토 경사(1:n) — 절토 계획선용(`cutDesign` 이 없을 때 경사 하나). */
cutSlopeRatio?: number;
/** 측점 설계값 — 있으면 절토선이 본선과 같은 복합경사(암 무릎 · 소단 · 91-4). */
cutDesign?: Partial<CrossDesign> | null;
adjust: BasinAdjust;
/** 벽 두께(m). 기본 = 집수정 부재 두께. 세월교 측벽이 다른 두께를 쓸 때 넘긴다. */
memberThicknessM?: number;
@@ -183,17 +186,8 @@ export const BASIN_INNER_WIDTH_M = 1.0;
/** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */
export function buildBasin(input: BasinBuildInput): BasinBuildResult {
const {
anchor: baseAnchor,
outward,
shape,
reason,
diameterM,
edge,
groundAt,
cutSlopeRatio,
adjust,
} = input;
const { anchor: baseAnchor, outward, shape, reason, diameterM, edge, groundAt, adjust } = input;
const cutSpec = structureCutSpec(input.cutDesign ?? { cut_slope_ratio: input.cutSlopeRatio });
/** 이 부재의 기움 — 집수정 0.3, 세월교 0(수직). */
const lean = input.leanRatio ?? REVET_LEAN_RATIO;
const movedAnchor = {
@@ -339,13 +333,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
: { offset: floorOuter, elevation: anchor.elevation };
const groundHere = groundAt(refTop.offset);
if (groundHere > refTop.elevation + 0.05) {
for (let h = 0.05; h <= 20; h += 0.05) {
const probe = refTop.offset + outward * cutSlopeRatio * h;
if (groundAt(probe) <= refTop.elevation + h) {
basinCut = { from: refTop, to: { offset: probe, elevation: refTop.elevation + h } };
break;
}
}
basinCut = structureCutPoints(refTop, outward, cutSpec, groundAt);
} else if (groundHere < refTop.elevation - 0.05) {
// 원지반 밖으로 나왔다 — 성토부선은 유출측과 같은 체계(1:1.2 + 5m 다단)로
// 본체가 그린다(2026-08-22 사용자 확정). 여기서는 시작점만 넘긴다.
@@ -29,6 +29,8 @@ import type {
WallAdjust,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { structureCutPoints } from "./B06_Section_UI_Cross_Structure_Cut";
import type { StructureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut";
import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
export interface OutletExtrasInput {
@@ -48,20 +50,19 @@ export interface OutletExtrasInput {
equalize?: boolean;
/** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */
leanFor?: (form: string, pureHeightM: number) => number;
/** 끝이 원지반에 묻힐 때 수평공간 뒤 절토 경사(1:n)로 지반까지(세월교 · 박스 흙막이 · 79-7).
* 없으면 종전 3도 선. */
cutBerm?: { lengthM: number; slopeRatio: number };
/** 끝이 원지반에 묻힐 때 수평공간 뒤 절토(본선과 같은 복합경사 · 91-4)로 지반까지(세월교 · 박스
* 흙막이 · 79-7). 없으면 종전 3도 선. */
cutBerm?: { lengthM: number; cut: StructureCutSpec };
}
/** 수평공간(berm) 뒤 절토 경사로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */
/** 수평공간(berm) 뒤 절토(복합경사 · 91-4)로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */
function bermCut(
src: OffsetPoint,
outward: number,
berm: { lengthM: number; slopeRatio: number },
berm: { lengthM: number; cut: StructureCutSpec },
groundAt: (offset: number) => number,
limitOffset: number,
): OutletFillSegment {
const ratio = Math.max(berm.slopeRatio, 0.1);
const flat = slopedCrossing(src, outward, 0, groundAt, limitOffset);
const segment = (points: OffsetPoint[]): OutletFillSegment => ({
points,
@@ -78,22 +79,16 @@ function bermCut(
if (flat !== null && (flat - src.offset) * outward <= berm.lengthM + 1e-9)
return segment([src, { offset: flat, elevation: src.elevation }]);
const bend = { offset: src.offset + outward * berm.lengthM, elevation: src.elevation };
const span = (limitOffset - bend.offset) * outward;
let previousGap = groundAt(bend.offset) - bend.elevation;
for (let t = 0.25; t <= span + 1e-9; t += 0.25) {
const gap = groundAt(bend.offset + outward * t) - (bend.elevation + t / ratio);
if (gap <= 0) {
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const cross = t - 0.25 * (1 - fraction);
return segment([
src,
bend,
{ offset: bend.offset + outward * cross, elevation: bend.elevation + cross / ratio },
]);
}
previousGap = gap;
}
return segment([src, bend]);
// 소단 셈은 수평공간 끝에서 새로(91-4)
const cut = structureCutPoints(
bend,
outward,
berm.cut,
groundAt,
(limitOffset - bend.offset) * outward,
);
const head = berm.lengthM > 1e-9 ? [src, bend] : [src];
return segment(cut ? [...head, ...cut.slice(1)] : head.length > 1 ? head : [src, bend]);
}
export interface OutletExtrasResult {
@@ -57,6 +57,7 @@ import {
inletChoiceAvailability,
} from "./B06_Section_UI_Cross_Culvert_Basin";
import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
import { withBackCut } from "./B06_Section_UI_Cross_Structure_Cut";
import {
buildCulvertTiers,
drawnFillPoints,
@@ -336,7 +337,7 @@ export function computeCulvertLayout(
diameterM: diameter,
edge: spec.role === "inlet" ? inletInfo.edge : outletInfo.edge,
groundAt,
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
cutDesign: section.design,
adjust: basinAdjust,
heightM: basinHeightM,
});
@@ -616,9 +617,8 @@ export function computeCulvertLayout(
const slopeOf = (wall: WallLayout): FillSlopeSegment =>
fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge);
for (const wall of walls) {
const slope = slopeOf(wall);
// 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의
// 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다).
const slope = withBackCut(slopeOf(wall), wall, section.design, groundAt, !culvert.hidden_pipe);
// 사면선은 노견부터 우리가 그림 — 트림을 노견까지 당겨 백엔드 설계선 성토 구간을 통째로 덮음
if (wall.outward > 0) {
trimMax = slope.points[0].offset;
trimMaxElevation = null;
@@ -16,6 +16,7 @@ import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
import { fillWallBaseWidth } from "./B06_Section_UI_Cross_Culvert_Solve";
import type { OffsetPoint, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean";
import { structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut";
// d의 0점 = **성토선 길이 0 지점**(벽 상단 = 노견 — 2026-08-23 사용자 확정).
// 그 자리(하단선 중점)는 이음선 상단점이 노견 끝에 오는 offset이고, 기본 자리는
@@ -87,7 +88,7 @@ export function buildCulvertTiers(input: {
// 세월교 · 박스 흙막이 끝 절토 = 컨테이너 수평공간 뒤 절토 경사(79-7) · 배관은 종전(3도 선).
const cutBerm = (lengthM: number) =>
culvert?.body
? { lengthM: Math.max(lengthM, 0), slopeRatio: section.design?.cut_slope_ratio ?? 1.0 }
? { lengthM: Math.max(lengthM, 0), cut: structureCutSpec(section.design) }
: undefined;
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
@@ -112,8 +112,8 @@ export interface BasinLayout {
* "cut" = 절토측 유입(성토사면 없음), "short" = 성토사면 3m 이하,
* "manual" = 규칙상 기슭막이 자리지만 사용자가 집수정을 선택. */
reason: "cut" | "short" | "manual";
/** 집수정이 원지반 안으로 들어갈 때의 절토선(구조물 바깥 끝 → 지반 교차점). */
cutLine: { from: OffsetPoint; to: OffsetPoint } | null;
/** 집수정이 원지반 안으로 들어갈 때의 절토선 점열(구조물 바깥 끝 → 복합경사 → 지반 교차점 · 91-4). */
cutLine: OffsetPoint[] | null;
/** 구조물이 원지반 밖으로 나올 때의 성토(되메움)선 — 경사 5도(임시), 지반 교차점까지. */
fillLine: { from: OffsetPoint; to: OffsetPoint } | null;
}
+1 -1
View File
@@ -150,7 +150,7 @@ export function appendFordOverlay(
);
}
if (side.cutLine) {
planLine([side.cutLine.from, side.cutLine.to], `${role} 측벽 절토선(계획선)`);
planLine(side.cutLine, `${role} 측벽 절토선(계획선)`);
}
}
@@ -148,7 +148,6 @@ export function computeFordLayout(
const uphill = section.uphill_side ?? "left";
const inletOnLeft = uphill === "left";
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
const cutSlopeRatio = section.design?.cut_slope_ratio ?? 1.0;
// 바닥판이 놓일 계류 하상 — 두 노견 사이 원지반 최저점.
const bedElevation = (() => {
@@ -182,7 +181,8 @@ export function computeFordLayout(
diameterM: ford.diameter_m,
edge: { offset_m: edge.offset_m, elevation_m: roadTopAt(edge.offset_m) },
groundAt,
cutSlopeRatio,
// 측벽 끝 절토 = 본선 절토 사면과 같은 복합경사(91-4)
cutDesign: section.design,
// 내공 폭 자리에 날개벽 투영 연장을 넣는다 — 바닥판이 그만큼 계류측으로 뻗는다.
adjust: {
innerWidthM: Math.max(wing.slab_extend_m, 0),
@@ -0,0 +1,107 @@
/* =============================================================================
* B06_Section_UI_Cross_Structure_Cut.ts
* 구조물 끝 → 원지반 **절토선 점열**(91-4) — 본선 절토 사면과 같은 복합경사.
*
* 꼭짓점은 `common_util_cross_berm.cutProfilePoints` 그대로(암 → 토사 무릎 · 소단) · 경계 = 원지반 +
* `rock_boundary_offset_m` · 켬 = 암 프리셋 + `two_stage_slope` · 소단 = `design.berm` · 소단 셈은
* 구조물 끝에서 새로 · 원지반 교점에서 자름.
* 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤.
* ========================================================================== */
import { bermSpec, cutProfilePoints } from "../common_util/common_util_cross_berm";
import type { BermSpec } from "../common_util/common_util_cross_berm";
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
/** 절토 제원 — 암(또는 단일) 경사 · 토사 경사 · 암 경계 오프셋(없으면 경사 하나) · 소단. */
export interface StructureCutSpec {
cutRatio: number;
soilRatio: number;
rockOffsetM: number | null;
berm: BermSpec | null;
}
/** 측점 설계값에서 절토 제원 — 본선 `computeCrossDesign` 과 같은 켬 조건. */
export function structureCutSpec(design?: Partial<CrossDesign> | null): StructureCutSpec {
const cutRatio = Math.max(design?.cut_slope_ratio ?? 1.0, 0.1);
const offset = design?.rock_boundary_offset_m;
const twoStage =
design?.geometry_preset === "rock" && design.two_stage_slope === true && offset != null;
const berm = design?.berm;
return {
cutRatio,
soilRatio: Math.max(design?.soil_cut_slope_ratio ?? cutRatio, 0.1),
rockOffsetM: twoStage ? offset : null,
berm: berm ? bermSpec(berm.width_m, berm.interval_m, berm.slope_deg) : null,
};
}
/** 원지반 교점 찾는 걸음(m). */
const STEP_M = 0.05;
/**
* `from`(원지반 아래) → 바깥(`outward`)으로 절토 꼭짓점 · 마지막 점 = 원지반 교점.
* `reachM` 안에서 못 만나면 null.
*/
export function structureCutPoints(
from: OffsetPoint,
outward: number,
spec: StructureCutSpec,
groundAt: (offset: number) => number,
reachM = 50,
): OffsetPoint[] | null {
const at = (dist: number): number => from.offset + outward * dist;
const rockOffsetM = spec.rockOffsetM;
const boundary = rockOffsetM === null ? null : (dist: number) => groundAt(at(dist)) + rockOffsetM;
const profile = cutProfilePoints(
0,
from.elevation,
spec.cutRatio,
spec.soilRatio,
boundary,
spec.berm,
Math.max(reachM, STEP_M),
// 소단이 있으면 경계를 오갈 때마다 꺾음 — 본선 `cutPoints` 와 같음.
spec.berm !== null,
);
const points: OffsetPoint[] = [from];
for (let i = 1; i < profile.length; i += 1) {
const [d0, z0] = profile[i - 1];
const [d1, z1] = profile[i];
const steps = Math.max(1, Math.ceil((d1 - d0) / STEP_M));
let previousGap = groundAt(at(d0)) - z0;
for (let k = 1; k <= steps; k += 1) {
const t = k / steps;
const gap = groundAt(at(d0 + (d1 - d0) * t)) - (z0 + (z1 - z0) * t);
if (gap <= 0) {
// 걸음 사이 교점 보간 — 선이 지반을 넘지 않게
const share = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const tc = (k - 1 + share) / steps;
points.push({ offset: at(d0 + (d1 - d0) * tc), elevation: z0 + (z1 - z0) * tc });
return points;
}
previousGap = gap;
}
points.push({ offset: at(d1), elevation: z1 });
}
return null;
}
/**
* 배관 유출 벽이 절토 쪽(벽 바깥 윗모서리가 원지반 아래)이면 그 모서리부터 절토선을 사면 점열 뒤에
* 이음 — 트림이 노견부터 설계선을 지워 절토사면이 사라지던 자리(91-4). 관 숨김(세월교 · 박스 흙막이 ·
* 독립 기슭막이)은 `enabled` 거짓.
*/
export function withBackCut<T extends { points: OffsetPoint[] }>(
slope: T,
wall: { role: string; outward: number; points: OffsetPoint[] },
design: Partial<CrossDesign> | null | undefined,
groundAt: (offset: number) => number,
enabled: boolean,
): T {
const corner = wall.points[2];
if (!enabled || wall.role !== "outlet" || !corner) return slope;
if (groundAt(corner.offset) <= corner.elevation + 0.05) return slope;
const cut = structureCutPoints(corner, wall.outward, structureCutSpec(design), groundAt);
return cut ? { ...slope, points: [...slope.points, ...cut] } : slope;
}
@@ -127,4 +127,69 @@ out.lean = ["left_cut", "right_cut", "both_fill", "both_cut"].map((mode) => [
leanOf({ form: "돌쌓기(찰)", height_m: 1.5, section_mode: mode, side: "좌" }),
leanOf({ form: "돌쌓기(찰)", height_m: 1.5, section_mode: mode, side: "좌", face_role: "절토" }),
]);
// ③ 91-4 유출측 절토 복합경사 — 꼭짓점 사이 경사(수평/수직) · 사면 길이 · 끝 = 원지반 교점
const { groundInterpolator } = loadTs(B06("B06_Section_UI_Cross_Culvert_Solve.ts"));
function profile(points, samples) {
const groundAt = groundInterpolator(samples);
const legs = points.slice(1).map((p, i) => {
const dx = Math.abs(p.offset - points[i].offset);
const dz = p.elevation - points[i].elevation;
return { ratio: dz > 1e-9 ? r3(dx / dz) : null, slant: r3(Math.hypot(dx, dz)), run: r3(dx) };
});
const end = points[points.length - 1];
return { legs, endGap: r3(Math.abs(groundAt(end.offset) - end.elevation)) };
}
const withBerm = (s) => {
s.design.berm = { width_m: 0.5, interval_m: 3, slope_deg: 0 };
};
// 흙막이 수평공간 뒤(박스 352.14 · 세월교 173.09)
function guardTails(base, edit = () => undefined) {
const s = clone(base);
edit(s);
const { culvert } = layoutsOf(s, base);
const tagged = (segments, role) => segments.map((g) => ({ ...g, role }));
return [
...tagged(culvert.outletFill.segments, "outlet"),
...tagged(culvert.basinFill.segments, "inlet"),
]
.filter((g) => g.kind === "cut")
.map((g) => ({ role: g.role, ...profile(g.points, s.samples) }));
}
out.guardTails = [...guardTails(at(352.14)), ...guardTails(FORD)];
// 박스 끝 · 세월교 측벽 끝(흙막이 없음)
const { computeBoxLayout } = loadTs(B06("B06_Section_UI_Cross_Box_Geom.ts"));
const { computeFordLayout } = loadTs(B06("B06_Section_UI_Cross_Ford_Geom.ts"));
function endCuts(base, compute) {
const s = clone(base);
delete s.culvert;
s.uphill_side = "left"; // 절토 쪽(우측)을 유출로
return compute(s, s.samples)
.sides.filter((side) => side.cutLine)
.map((side) => profile(side.cutLine, s.samples));
}
out.boxEnd = endCuts(at(352.14), computeBoxLayout);
out.fordEnd = endCuts(FORD, computeFordLayout);
// 절토 쪽 배관 유출 벽 뒤(관 340 · 유출 = 우측 절토) · 소단 있음/없음
function cutOutlet(edit = () => undefined) {
const s = clone(at(340));
s.uphill_side = "left";
s.culvert = {
type: "pipe",
chainage_m: 340,
pipe_kind: "흄관",
diameter_m: 1.0,
min_cover_m: 0.5,
inlet: { role: "inlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" },
outlet: { role: "outlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" },
};
s.design.inlet_structure = "revet";
edit(s);
const layout = computeCulvertLayout(s, s.samples, undefined, "revet");
const wall = layout.walls.find((w) => w.role === "outlet");
const pts = layout.designTrim.minSlope.points;
const from = pts.findIndex((p) => Math.abs(p.offset - wall.points[2].offset) < 1e-9);
return profile(pts.slice(from), s.samples);
}
out.cutOutlet = cutOutlet();
out.cutOutletBerm = cutOutlet(withBerm);
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,304 @@
/* =============================================================================
* test_stage4_menu_items.ts — 우클릭 메뉴 덧붙인 항목(81-3) 시험.
* 행 위/아래 · 열 좌우 삽입 · 셀 삽입/삭제 · 행열 삭제 · 크기 · 시트 정렬 · 필터 · 고정 · 그룹 ·
* 범위 이름 · 붙여넣기 보안 문맥 갈래 · 구분선 · 읽기 전용 복사.
* 돌리기: npx tsx resources/tester/spreadsheet/test_stage4_menu_items.ts
* ========================================================================== */
import { register } from "node:module";
import type { MapContextMenuItem } from "../../../ui_template/ui_template_context_menu.ts";
import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands.ts";
import { createCalcEngine } from "../../../A00_Common/spreadsheet/spreadsheet_graph.ts";
import {
canMenuPaste,
cellInsertItems,
cellMoreItems,
colInsertItems,
colMoreItems,
copyOnlyItems,
menuPaste,
nameRange,
pasteItems,
resizeLines,
rowInsertItems,
rowMoreItems,
sortByColumn,
usedRange,
} from "../../../A00_Common/spreadsheet/spreadsheet_menu_items.ts";
import type { Cell, Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts";
import type {
Selection,
SpreadsheetContext,
} from "../../../A00_Common/spreadsheet/spreadsheet_view_types.ts";
import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts";
// 늦게 부르는 창 모듈의 css 는 node 에서 빈 모듈로
register(
"data:text/javascript,export async function load(u,c,n){return u.endsWith('.css')?{format:'module',source:'',shortCircuit:true}:n(u,c)}",
);
const tick = () => new Promise((r) => setTimeout(r, 30));
/** 진짜 명령 · 엔진 · 고름을 쓰는 문맥 */
function realCtx(cells: Record<string, Cell>): SpreadsheetContext & { sent: Command[] } {
const book: Workbook = makeBook([makeSheet("s1", cells)]);
const base = makeCtx(book, "s1");
const sent: Command[] = [];
const ctx = base as unknown as SpreadsheetContext & { sent: Command[] };
ctx.engine = createCalcEngine(book);
ctx.sent = sent;
ctx.dispatch = (cmd: Command) => {
sent.push(cmd);
applyCommand(book, cmd);
ctx.engine.rebuild(book);
};
ctx.select = (sel: Selection) => void (ctx.selection = sel);
return ctx;
}
const col = (n: number) =>
Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(i, 0), v(i + 1)]));
const row = (n: number) =>
Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(0, i), v(i + 1)]));
const val = (ctx: SpreadsheetContext, r: number, c: number) => ctx.sheet().칸[a1(r, c)]?.값;
const leaf = (items: MapContextMenuItem[], label: string): (() => void) => {
for (const it of items) {
if (it === "-") continue;
if (Array.isArray(it)) {
if (it[0] === label) return it[1];
} else {
const hit = it.children.find(([l]) => l === label);
if (hit) return hit[1];
}
}
throw new Error(`항목 없음: ${label}`);
};
const labels = (items: MapContextMenuItem[]) =>
items.map((it) => (it === "-" ? "-" : Array.isArray(it) ? it[0] : `${it.label} ▸`));
// ── 행 위 · 아래 삽입(고른 2행 B2:B3 → at 1 · 수 2) ─────────────────────────
{
const ctx = realCtx(col(5));
leaf(rowInsertItems(ctx, 1, 2), "위에 2행 삽입")();
assertEqual(
[val(ctx, 0, 0), val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)],
[1, undefined, undefined, 2],
"위에 2행 — 2행부터 밀림",
);
}
{
const ctx = realCtx(col(5));
leaf(rowInsertItems(ctx, 1, 2), "아래에 2행 삽입")();
assertEqual(
[val(ctx, 2, 0), val(ctx, 3, 0), val(ctx, 4, 0), val(ctx, 5, 0)],
[3, undefined, undefined, 4],
"아래에 2행 — 고른 줄 뒤에 끼움",
);
}
// ── 열 왼 · 오른쪽 삽입 ──────────────────────────────────────────────────
{
const ctx = realCtx(row(4));
leaf(colInsertItems(ctx, 1, 1), "왼쪽에 1열 삽입")();
assertEqual([val(ctx, 0, 0), val(ctx, 0, 1), val(ctx, 0, 2)], [1, undefined, 2], "왼쪽에 1열");
}
{
const ctx = realCtx(row(4));
leaf(colInsertItems(ctx, 1, 1), "오른쪽에 1열 삽입")();
assertEqual([val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)], [2, undefined, 3], "오른쪽에 1열");
}
// ── 칸 메뉴 — 셀 삽입/삭제 ▸ · 행열 삭제 ▸ ──────────────────────────────
{
const g = { r0: 0, c0: 1, r1: 0, c1: 1 };
const ctx = realCtx(row(3));
const items = cellInsertItems(ctx, g);
assertEqual(
labels(items),
["행 · 열 삽입 ▸", "셀 삽입 ▸", "행 · 열 삭제 ▸", "셀 삭제 ▸"],
"칸 메뉴 하위 넷",
);
leaf(items, "셀 삽입 후 오른쪽으로 밀기")();
await tick();
assertEqual(
[val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)],
[undefined, 2, 3],
"셀 삽입 · 오른쪽으로",
);
leaf(cellInsertItems(ctx, g), "셀 삭제 후 왼쪽으로 당기기")();
await tick();
assertEqual([val(ctx, 0, 1), val(ctx, 0, 2)], [2, 3], "셀 삭제 · 왼쪽으로");
}
{
const ctx = realCtx(col(3));
leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삽입 후 아래로 밀기")();
await tick();
assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [undefined, 2], "셀 삽입 · 아래로");
leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삭제 후 위로 당기기")();
await tick();
assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [2, 3], "셀 삭제 · 위로");
leaf(cellInsertItems(ctx, { r0: 0, c0: 0, r1: 1, c1: 0 }), "2행 삭제")();
assertEqual(val(ctx, 0, 0), 3, "칸 메뉴 행 삭제 · 고른 2행");
}
// ── 크기 — px → 열폭(글자) · 행높이(pt) · 잘못된 값 ─────────────────────────
{
const ctx = realCtx(row(2));
assertEqual(
resizeLines(ctx, false, [0, 1], "abc"),
"1 이상 숫자를 넣을 것",
"크기 · 글이면 거름",
);
assertEqual(resizeLines(ctx, false, [0, 1], "0"), "1 이상 숫자를 넣을 것", "크기 · 0 거름");
assertEqual(resizeLines(ctx, false, [0, 1], "100"), null, "열 너비 100px");
const w = ctx.sheet().열!;
assertEqual(w.A?.폭 === w.B?.폭 && (w.A?.폭 ?? 0) > 10, true, "열 둘 같은 폭(한 명령)");
resizeLines(ctx, true, [2], "40");
assertEqual(
Math.round((ctx.sheet().행?.["3"]?.높이 ?? 0) * 100) / 100,
30,
"행 높이 40px = 30pt",
);
}
// ── 시트 정렬(열 머리) — 쓴 범위 통째 · 고름 되돌림 · 빈 열은 안 함 ───────────────
{
const ctx = realCtx({
A1: v("이름"),
B1: v("값"),
A2: v("다"),
B2: v(3),
A3: v("가"),
B3: v(1),
A4: v("나"),
B4: v(2),
});
const keep = ctx.selection;
await sortByColumn(ctx, 1, false);
assertEqual(
[val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)],
["가", "나", "다"],
"B 열 기준 오름 — A 열도 같이",
);
assertEqual(val(ctx, 0, 0), "이름", "머리 행 그대로");
assertEqual(ctx.selection, keep, "정렬 뒤 고름 되돌림");
await sortByColumn(ctx, 1, true);
assertEqual([val(ctx, 1, 1), val(ctx, 3, 1)], [3, 1], "내림");
const before = ctx.sent.length;
await sortByColumn(ctx, 5, false);
assertEqual(ctx.sent.length, before, "빈 열 정렬은 명령 없음");
assertEqual(usedRange(ctx.sheet()), { r0: 0, c0: 0, r1: 3, c1: 1 }, "쓴 범위");
}
// ── 필터 · 고정 · 그룹 · 더보기 ─────────────────────────────────────────────
{
const ctx = realCtx({ A1: v("머리"), A2: v(1), B2: v(2) });
const calls: [string, unknown][] = [];
ctx.dataMenu = (kind) => calls.push([kind, structuredClone(ctx.selection.범위)]);
const more = colMoreItems(ctx, [0]);
assertEqual(
labels(more),
[
"-",
"열 크기 조절…",
"데이터에 맞추기",
"-",
"시트 정렬 A → Z",
"시트 정렬 Z → A",
"필터 만들기",
"-",
"고정 ▸",
"그룹 ▸",
],
"열 머리 덧붙임 순서",
);
leaf(more, "필터 만들기")();
await tick();
assertEqual(calls[0], ["filter", [{ r0: 0, c0: 0, r1: 1, c1: 1 }]], "열 머리 필터 = 쓴 범위로");
leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "드롭다운…")();
leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "조건부 서식…")();
assertEqual(
calls.slice(1).map((c) => c[0]),
["validation", "condfmt"],
"더보기 → 데이터 창",
);
leaf(rowMoreItems(ctx, [0]), "첫 행 고정")();
await tick();
assertEqual(ctx.sent.at(-1)?.종류, "틀고정", "고정 ▸ 첫 행");
ctx.selection = {
시트: "s1",
범위: [{ r0: 1, c0: 0, r1: 1, c1: 16383 }],
활성: { r: 1, c: 0 },
기준: { r: 1, c: 0 },
};
leaf(rowMoreItems(ctx, [1]), "그룹으로 묶기")();
await tick();
assertEqual(ctx.sheet().행?.["2"]?.묶음, { 층: 1 }, "그룹 ▸ 묶기");
assertEqual(
await nameRange(ctx, "", { r0: 0, c0: 0, r1: 0, c1: 0 }),
"이름을 넣을 것",
"범위 이름 · 빈 값",
);
assertEqual(
await nameRange(ctx, "합계칸", { r0: 1, c0: 0, r1: 1, c1: 1 }),
null,
"범위 이름 지정",
);
assertEqual(
typeof (await nameRange(ctx, "합계칸", { r0: 0, c0: 0, r1: 0, c1: 0 })),
"string",
"같은 이름은 거름",
);
}
// ── 붙여넣기 — 보안 문맥 갈래 ───────────────────────────────────────────────
{
const g = globalThis as Record<string, unknown>;
g.isSecureContext = false;
assertEqual(canMenuPaste(), false, "http(보안 문맥 아님) — 메뉴로 못 읽음 → Ctrl+V 안내");
g.isSecureContext = true;
assertEqual(canMenuPaste(), false, "클립보드 API 없음 — 안내");
const clip = { html: "", text: "7\t8" };
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
read: async () => [
{
types: ["text/plain"],
getType: async () => new Blob([clip.text]),
},
],
},
});
assertEqual(canMenuPaste(), true, "localhost · https — 메뉴로 읽음");
class FakeData {
data: Record<string, string> = {};
setData(k: string, s: string) {
this.data[k] = s;
}
}
class FakePaste {
constructor(
public type: string,
public init: { clipboardData: FakeData },
) {}
}
g.DataTransfer = FakeData;
g.ClipboardEvent = FakePaste;
const ctx = realCtx({});
const events: FakePaste[] = [];
ctx.root = { dispatchEvent: (e: FakePaste) => events.push(e) } as unknown as HTMLElement;
await menuPaste(ctx);
assertEqual(
[events[0]?.type, events[0]?.init.clipboardData.data["text/plain"]],
["paste", "7\t8"],
"보통 붙여넣기 = 부품 paste 길로",
);
ctx.selection = {
시트: "s1",
범위: [{ r0: 2, c0: 0, r1: 2, c1: 0 }],
활성: { r: 2, c: 0 },
기준: { r: 2, c: 0 },
};
await menuPaste(ctx, "값");
assertEqual([val(ctx, 2, 0), val(ctx, 2, 1)], [7, 8], "값만 붙여넣기 — 활성 칸부터");
assertEqual(labels(pasteItems(ctx)), ["붙여넣기", "선택하여 붙여넣기 ▸"], "붙여넣기 항목");
}
// ── 읽기 전용 — 복사 한 줄 ─────────────────────────────────────────────────
{
const ctx = realCtx({});
assertEqual(labels(copyOnlyItems(ctx)), ["복사"], "읽기 전용 · 미리보기 = 복사 한 줄");
}
console.log("spreadsheet_menu_items 시험 끝");
+12 -5
View File
@@ -81,12 +81,19 @@ def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None:
assert tails, "절토 끝선이 없음"
assert any(tail["role"] == "inlet" for tail in tails)
for tail in tails:
assert tail["points"] == 3
assert tail["flat"] == pytest.approx(0, abs=1e-6)
# 유출은 수평공간 없음 — 묻히면 바로 절토(89-4)
assert tail["berm"] == pytest.approx(1.0 if tail["role"] == "inlet" else 0, abs=1e-3)
# 시작 · 수평공간 끝 · (암 무릎 · 소단) · 원지반 교점 — 본선 절토와 같은 복합경사(91-4)
assert tail["points"] >= 2
if tail["role"] == "inlet":
assert tail["flat"] == pytest.approx(0, abs=1e-6)
assert tail["berm"] == pytest.approx(1.0, abs=1e-3)
else:
# 유출은 수평공간 없음 — 묻히면 첫 꺾임부터 바로 절토(89-4)
assert tail["flat"] > 0
for tail in shape["cutBerm2"]:
assert tail["berm"] == pytest.approx(2.0 if tail["role"] == "inlet" else 0, abs=1e-3)
if tail["role"] == "inlet":
assert tail["berm"] == pytest.approx(2.0, abs=1e-3)
else:
assert tail["flat"] > 0
def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None:
+33
View File
@@ -83,3 +83,36 @@ def test_지형에_막힘_잔재_없음() -> None:
source = (ROOT / "B06_Section" / name).read_text(encoding="utf-8")
assert "shiftBlockedM" not in source and "지형에 막힘" not in source, name
assert "noticeExtraLimit" not in source and "noticeShortfall" not in source, name
def _knee(cut: dict) -> bool:
"""암(1:0.4) 뒤 토사(1:1.0) 무릎이 있음 — 픽스처 = 리핑암 · 2단계 · 경계 −0.5 m."""
ratios = [leg["ratio"] for leg in cut["legs"] if leg["ratio"] is not None]
return any(a == 0.4 and b == 1.0 for a, b in zip(ratios, ratios[1:]))
def test_흙막이_수평공간_뒤_절토_복합경사(fit: dict) -> None:
tails = fit["guardTails"]
assert tails and all(cut["endGap"] < 0.01 for cut in tails)
# 유입 = 수평공간 먼저 · 유출 = 수평공간 없이 바로 절토(89-4)
for cut in tails:
assert (cut["legs"][0]["ratio"] is None) == (cut["role"] == "inlet")
assert sum(_knee(cut) for cut in tails) >= 2
def test_박스_끝_세월교_측벽_끝_절토_복합경사(fit: dict) -> None:
for cuts in (fit["boxEnd"], fit["fordEnd"]):
assert cuts and all(_knee(cut) and cut["endGap"] < 0.01 for cut in cuts)
def test_절토_쪽_배관_유출_벽_뒤_절토사면_살아남(fit: dict) -> None:
cut = fit["cutOutlet"]
assert _knee(cut) and cut["endGap"] < 0.01
def test_소단은_구조물_끝에서_새로_셈(fit: dict) -> None:
legs = fit["cutOutletBerm"]["legs"]
flat = next(i for i, leg in enumerate(legs) if leg["ratio"] is None)
assert sum(leg["slant"] for leg in legs[:flat]) == pytest.approx(3.0, abs=1e-3)
assert legs[flat]["run"] == pytest.approx(0.5, abs=1e-3)
assert fit["cutOutletBerm"]["endGap"] < 0.01