Files
Aislo/common_util/common_util_cross_design.ts
T
eomsangdonandClaude Opus 5 061d550e55 feat(횡단): 소단을 미리보기·재계산에 실어 화면에 계단이 서게 함 (계획서 3-9)
계단이 값에만 있던 것을 화면까지 연결함.

배선 — 세션 열쇠 `berm`(측점키 → 폭·간격·기울기)을 등록표에 두고,
`readBermSession` 으로 읽어 ① 브라우저 재계산(`refreshCrossDesigns`)과
② 서버 미리보기(`cross-design/preview` 의 `berms`) 양쪽에 실음.
암 경계선 오프셋과 같은 길이라 「계획선을 고치면 계단이 사라지는」 일이 없음.

실화면 확인(8001·5174, `/api/health` `stale:false`) — 측점 4120.0m 에 소단을 놓고
계획고를 한 칸 올렸다 내려 전 구간 재계산을 태움.
· 폭 0.5m · 간격 3.0m → 절토 6.84 → 8.10㎡ (토사 2.42→3.24 · 암 4.42→4.86)
· 폭 1.0m · 간격 2.0m → 절토 6.84 → 12.96㎡, 횡단도에 **계단이 눈으로 보임**
· 소단을 안 놓은 옆 측점(4100.0m)은 3.77㎡ 그대로 — 놓은 곳만 달라짐
· 되돌린 뒤 6.84㎡ 로 복귀. [저장]·[확정] 안 눌렀으므로 정본은 그대로.

`cut_slope_segments` 신설 — 절토 사면을 경사 구간별로 쪼갠 목록(파이썬·TS 짝).
법정 경사 검사가 읽을 값임(다른 창 요청). 소단이 서면 사면 전체를 하나로 재는
「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보이므로(폭 1.0·간격 2 이면 설계 1:1 이
실효 1:1.71), 검사는 소단을 뺀 구간 자체를 봐야 함. 실측 — 소단을 놓아도 구간별 경사비는
1.0 그대로 나옴. 평탄부(소단)와 지반 만난 뒤 구간은 싣지 않음.

자체검증 — 거울 시험에 사면 구간 대조를 더해 파이썬·TS 일치 확인.
전체 539 passed · 18 skipped. TS 타입 검사 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:28:16 +09:00

454 lines
19 KiB
TypeScript

/* =============================================================================
* common_util_cross_design.ts
* B06 측점 표준횡단 설계 계산 — **브라우저 판**.
*
* ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠
* 짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`)
* 면적 적분은 `common_util_cross_design_areas.ts` ↔ `B06_Section_Engine_Areas.py`.
* 회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다:
* `tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것.
*
* ── 왜 같은 계산이 두 벌인가 (2026-09-03 사용자 확정) ────────────────
* 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야 한다**. 조작은 세션
* 캐시에 쌓이고 화면은 즉시 따라오며, 서버는 [저장]·[확정]에서만 부른다. 계획고가 바뀔
* 때마다 서버에 전 측점 횡단을 물으면 왕복이 조작 속도를 지배한다(2026-09-03 실측 보고).
* 계획선 선형(`B05_Profile_Engine_Grade_Alignment.py` ↔ `B05_Profile_UI_Profile_Alignment.ts`)
* 이 이미 같은 이유로 1:1 미러다.
*
* ── 갈라지지 않게 하는 규칙 ──────────────────────────────────────────
* 1. **config 상수를 여기에 복제하지 않는다.** 표준단면 수치는 서버가 config 에서 내려
* 주는 `sections/context.standard_cross_section` 을 입력으로 받는다. 아래 상수는
* config 의 *열거값*(지반유형→프리셋 등)뿐이며 그마저 짝 파일과 나란히 둔다.
* 2. 반올림·경계 판정 상수까지 파이썬과 같은 값을 쓴다.
* 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다.
* ========================================================================== */
import type { BermSpec } from "./common_util_cross_berm";
import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
// 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04).
import {
CURVE_WIDENING_MAX_WIDTH_M,
type CutSlopeSegment,
SectionGeometry,
curveWideningM,
type ResolvedGroup,
} from "./common_util_cross_design_geometry";
/** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */
const GROUND_TYPE_PRESET: Record<string, string> = {
soil: "soil",
ripping_rock: "rock",
blasting_rock: "rock",
};
/** 단면유형. 짝: config `SECTION_MODES`. 좌=양(+)offset, 우=음(-)offset. */
const SECTION_MODES = ["left_cut", "right_cut", "both_cut", "both_fill"];
/** 짝: config `SECTION_DITCH_SIDES` / `SECTION_DITCH_TYPES`. */
const DITCH_SIDES = ["left", "right"];
const DITCH_TYPES = ["standard", "l_type"];
/** 사면이 원지반과 만났다고 볼 높이차(m). 짝: `_SLOPE_CLOSE_TOLERANCE_M`. */
const SLOPE_CLOSE_TOLERANCE_M = 0.01;
export interface CrossGroundSample {
offset_m?: number | null;
elevation_m?: number | null;
valid?: boolean;
}
/** `sections/context.standard_cross_section` 한 그룹의 모양(서버가 config 에서 내려 준다). */
export interface StandardGroupSpec {
road_width_m?: number;
shoulder_left_m?: number;
shoulder_right_m?: number;
ditch?: { top_width_m?: number; bottom_width_m?: number; depth_m?: number };
ditch_l_type?: { width_m?: number; depth_m?: number };
cross_slope_pct?: { min?: number; max?: number };
fill_slope_ratio?: number;
cut_slope_ratio?: number;
pavement_thickness_m?: number;
}
/** 프리셋 키(soil/rock/paved) → 그룹 값. 세션 편집값도 같은 모양이다. */
export type StandardCrossSectionSpec = Record<string, StandardGroupSpec | undefined>;
export interface CrossDesignOptions {
groundType: string;
sectionMode: string;
ditchSide?: string | null;
ditchType?: string;
paved?: boolean;
/** 표준단면 수치(필수) — config 기본값 또는 그 위에 얹은 세션 편집값. */
standard: StandardCrossSectionSpec;
rockBoundaryOffsetM?: number | null;
twoStageSlope?: boolean;
ditchEnabled?: boolean | null;
/** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */
surfaceDropM?: number;
/** 이 측점의 평면 곡선반경(m) — 곡선부 확폭을 정하는 입력. 직선이면 null. */
planRadiusM?: number | null;
/** 곡선 **바깥쪽**("left"/"right") — 확폭이 붙는 쪽(2026-09-06 사용자 확정). */
curveOuterSide?: "left" | "right" | null;
/** 저장된 확폭량(m) — 곡선 앞뒤 테이퍼가 얹힌 값. 있으면 반경 표값 대신 쓴다. */
curveWideningM?: number | null;
/** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
berm?: BermSpec | null;
}
export interface CrossDesignEdge {
offset_m: number;
elevation_m: number;
}
export interface CrossDesignResult {
ground_type: string;
geometry_preset: string;
section_mode: string;
ditch_side: string;
ditch_type: string | null;
cut_slope_ratio: number;
soil_cut_slope_ratio: number;
two_stage_slope: boolean;
fill_slope_ratio: number;
roadbed_width_m: number;
carriageway_width_m: number;
/** 규격 차도 폭(확폭 전, m) — 확폭 라벨·수량이 둘을 나눠 쓴다(2026-09-06). */
carriageway_standard_width_m?: number;
/** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */
widening_left_m?: number;
widening_right_m?: number;
cross_slope_pct: number;
ditch: Record<string, unknown>;
ditch_enabled: boolean;
paved: boolean;
road_edges: { left: CrossDesignEdge; right: CrossDesignEdge };
carriageway_edges: { left: CrossDesignEdge; right: CrossDesignEdge };
design_elevation_m: number;
cut_area_m2: number;
cut_soil_area_m2: number;
cut_rock_area_m2: number;
cut_rock_kind: string | null;
fill_area_m2: number;
slope_unclosed: boolean;
fill_ground_slope: number | null;
ditch_area_m2: number;
design_line: CrossDesignEdge[];
/** 절토 사면 경사 구간(소단 제외) — 법정 경사 검사가 읽는다. 짝: `cut_slope_segments`. */
cut_slope_segments: CutSlopeSegment[];
surface_drop_m?: number;
pavement_thickness_m?: number;
rock_boundary_offset_m?: number;
}
/** 짝: 파이썬 `round(value, 4)`. */
function round4(value: number): number {
return Math.round(value * 1e4) / 1e4;
}
/** 짝: `_as_float` — 손상값·음수면 fallback. */
function asFloat(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) return fallback;
return parsed >= 0 ? parsed : fallback;
}
/**
* 짝: `_resolve_group`. 파이썬은 config 위에 패널 편집값을 덮지만, 여기서는 이미 그렇게
* 합쳐진 한 벌(`standard`)을 받는다 — config 수치를 프론트에 복제하지 않기 위함이다.
*/
function resolveGroup(presetKey: string, standard: StandardCrossSectionSpec): ResolvedGroup {
const group = standard[presetKey];
if (!group) {
throw new Error(`표준횡단 설정에 '${presetKey}' 그룹이 없어 횡단 설계를 계산할 수 없습니다.`);
}
const ditch = group.ditch ?? {};
const lDitch = group.ditch_l_type ?? {};
const slope = group.cross_slope_pct ?? {};
return {
road_width_m: asFloat(group.road_width_m, 0),
shoulder_left_m: asFloat(group.shoulder_left_m, 0),
shoulder_right_m: asFloat(group.shoulder_right_m, 0),
ditch_top_width_m: asFloat(ditch.top_width_m, 0),
ditch_bottom_width_m: asFloat(ditch.bottom_width_m, 0),
ditch_depth_m: asFloat(ditch.depth_m, 0),
// 짝 파일의 `base.get("width_m", 0.5)`와 같은 최후 기본값.
l_ditch_width_m: asFloat(lDitch.width_m, 0.5),
l_ditch_depth_m: asFloat(lDitch.depth_m, 0.1),
// 횡단경사는 범위(min~max) 중 하한을 기본 채택한다(도면 표기 앞값).
cross_slope_pct: asFloat(slope.min, 0),
fill_slope_ratio: asFloat(group.fill_slope_ratio, 0),
cut_slope_ratio: asFloat(group.cut_slope_ratio, 0),
pavement_thickness_m: asFloat(group.pavement_thickness_m, 0.2),
};
}
/** 짝: `_resolve_ditch_side`. */
function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefined): string {
if (sectionMode === "left_cut") return "left";
if (sectionMode === "right_cut") return "right";
if (ditchSide && DITCH_SIDES.includes(ditchSide)) return ditchSide;
return "left";
}
/**
* 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프).
*
* 짝 파일은 앞에서부터 훑지만 여기서는 이진탐색으로 같은 구간을 고른다 — 사면·지반
* 교차 행진이 이 함수를 수만 번 부르는데 브라우저에서는 그 비용이 조작 속도에 바로
* 드러나기 때문이다. **결과는 완전히 같다**(같은 구간, 같은 선형보간).
*/
function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) => number {
const last = valid.length - 1;
return (offsetM: number): number => {
if (offsetM <= valid[0][0]) return valid[0][1];
if (offsetM >= valid[last][0]) return valid[last][1];
let low = 1;
let high = last;
while (low < high) {
const mid = (low + high) >> 1;
if (valid[mid][0] < offsetM) low = mid + 1;
else high = mid;
}
const [x1, z1] = valid[low];
const [x0, z0] = valid[low - 1];
const span = x1 - x0;
if (span <= 0) return z1;
return z0 + (z1 - z0) * ((offsetM - x0) / span);
};
}
/**
* 짝: `compute_cross_design`. 측점 하나의 표준횡단 설계선과 절·성토 단면적을 낸다.
*
* `samples` 는 지반선 원시 샘플, `designElevationM` 은 중심선 계획고(노면고)다.
* 계산 불가(계획고 없음·샘플 부족·잘못된 유형)면 던진다 — 호출부가 그 측점을 건너뛴다.
*/
export function computeCrossDesign(
samples: CrossGroundSample[],
designElevationM: number | null | undefined,
options: CrossDesignOptions,
): CrossDesignResult {
const groundType = options.groundType;
const sectionMode = options.sectionMode;
const ditchType = options.ditchType ?? "standard";
const paved = Boolean(options.paved);
if (!(groundType in GROUND_TYPE_PRESET)) {
throw new Error(`지원하지 않는 지반유형입니다: ${groundType}`);
}
if (!SECTION_MODES.includes(sectionMode)) {
throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`);
}
if (!DITCH_TYPES.includes(ditchType)) {
throw new Error(`지원하지 않는 측구 형식입니다: ${ditchType}`);
}
if (designElevationM === null || designElevationM === undefined) {
throw new Error("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다.");
}
const drop = Math.max(options.surfaceDropM ?? 0, 0);
const centerElevation = designElevationM - drop;
const presetKey = GROUND_TYPE_PRESET[groundType];
if (ditchType === "l_type" && presetKey !== "rock") {
throw new Error("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.");
}
const group = resolveGroup(presetKey, options.standard);
const pavedGroup = resolveGroup("paved", options.standard);
// 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다.
const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct;
const resolvedDitchSide = resolveDitchSide(sectionMode, options.ditchSide);
const valid: Array<[number, number]> = samples
.filter(
(sample) =>
sample.valid !== false &&
sample.offset_m !== null &&
sample.offset_m !== undefined &&
sample.elevation_m !== null &&
sample.elevation_m !== undefined,
)
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number])
.sort((a, b) => a[0] - b[0]);
if (valid.length < 2) {
throw new Error("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.");
}
const groundAt = groundInterpolator(valid);
const rockBoundaryOffsetM = options.rockBoundaryOffsetM ?? null;
// 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다.
const enableTwoStage =
presetKey === "rock" && (options.twoStageSlope ?? true) && rockBoundaryOffsetM !== null;
// 곡선부 확폭 — 표는 하한이고, 확폭을 더한 유효너비가 법정 상한(5m)을 넘지 않게 자른다.
// 짝: 파이썬 `compute_cross_design`. 표·상한 값은 config 한 곳에서 온다.
const outerSide = options.curveOuterSide;
// 저장된 확폭량(테이퍼 포함)이 있으면 그것을 쓰고, 없으면 반경 표값으로 되돌아간다.
let widening =
outerSide === "left" || outerSide === "right"
? typeof options.curveWideningM === "number" && Number.isFinite(options.curveWideningM)
? options.curveWideningM
: curveWideningM(options.planRadiusM)
: 0;
if (widening > 0) {
widening = Math.min(widening, Math.max(CURVE_WIDENING_MAX_WIDTH_M - group.road_width_m, 0));
}
const geometry = new SectionGeometry({
designElevationM: centerElevation,
wideningLeftM: outerSide === "left" ? widening : 0,
wideningRightM: outerSide === "right" ? widening : 0,
group,
sectionMode,
ditchSide: resolvedDitchSide,
ditchType,
crossSlopePct,
groundAt,
soilCutRatio: resolveGroup("soil", options.standard).cut_slope_ratio,
rockBoundaryOffsetM,
twoStageSlope: enableTwoStage,
ditchEnabled: options.ditchEnabled ?? null,
berm: options.berm ?? null,
});
// 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만).
const minOffset = valid[0][0];
const maxOffset = valid[valid.length - 1][0];
const mergedSet = new Set<number>(valid.map(([offset]) => round6(offset)));
for (const point of geometry.breakpoints()) {
if (point >= minOffset && point <= maxOffset) mergedSet.add(round6(point));
}
const merged = [...mergedSet].sort((a, b) => a - b);
const offsets: number[] = [];
const diffs: number[] = [];
const designLine: CrossDesignEdge[] = [];
for (const offsetM of merged) {
const groundM = groundAt(offsetM);
const designZ = geometry.designZ(offsetM, groundM);
offsets.push(offsetM);
diffs.push(groundM - designZ);
designLine.push({ offset_m: round4(offsetM), elevation_m: round4(designZ) });
}
// 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음).
const [cutArea, fillArea] = trapezoidAreas(offsets, diffs);
const fillGroundSlope = geometry.fillGroundSlope();
const slopeUnclosed =
diffs.length > 0 &&
(Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M ||
Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M);
// 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암.
let cutSoilArea: number;
let cutRockArea: number;
let cutRockKind: string | null;
if (presetKey !== "rock") {
cutSoilArea = cutArea;
cutRockArea = 0;
cutRockKind = null;
} else if (rockBoundaryOffsetM === null) {
cutSoilArea = 0;
cutRockArea = cutArea;
cutRockKind = groundType;
} else {
[cutSoilArea, cutRockArea] = splitCutAreas(offsets, diffs, Math.abs(rockBoundaryOffsetM));
cutRockKind = groundType;
}
// 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사.
let ditchArea: number;
let ditchSpec: Record<string, unknown>;
if (!geometry.hasDitch) {
ditchArea = 0;
ditchSpec = { type: "none" };
} else if (ditchType === "l_type") {
ditchArea = (group.l_ditch_width_m * group.l_ditch_depth_m) / 2;
ditchSpec = {
type: "l_type",
width_m: group.l_ditch_width_m,
depth_m: group.l_ditch_depth_m,
};
} else {
ditchArea = ((group.ditch_top_width_m + group.ditch_bottom_width_m) / 2) * group.ditch_depth_m;
ditchSpec = {
type: "standard",
top_width_m: group.ditch_top_width_m,
bottom_width_m: group.ditch_bottom_width_m,
depth_m: group.ditch_depth_m,
};
}
// 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo 한다(D-2).
let resolvedMode: string;
if (geometry.leftRole === "cut" && geometry.rightRole === "cut") resolvedMode = "both_cut";
else if (geometry.leftRole === "fill" && geometry.rightRole === "fill")
resolvedMode = "both_fill";
else if (geometry.leftRole === "cut") resolvedMode = "left_cut";
else resolvedMode = "right_cut";
const result: CrossDesignResult = {
ground_type: groundType,
geometry_preset: presetKey,
section_mode: resolvedMode,
ditch_side: resolvedDitchSide,
ditch_type: geometry.hasDitch ? ditchType : null,
cut_slope_ratio: round4(geometry.cutRatio),
soil_cut_slope_ratio: round4(geometry.soilCutRatio),
// 사용자가 **켠 값**을 그대로 돌려준다 — 엔진이 실제로 적용했는지(`geometry.twoStage`)가
// 아니다(2026-09-07, 짝: `B06_Section_Engine_Design.py`). 적용 결과를 저장하면 토사
// 측점처럼 못 쓰는 자리에서 false 가 저장되고, 그 false 가 다음 재계산 인자로 되먹여져
// 사용자의 「켬」이 영구히 사라졌다.
two_stage_slope: options.twoStageSlope ?? true,
fill_slope_ratio: round4(geometry.fillRatio),
roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent),
carriageway_width_m: round4(geometry.halfRoadLeft + geometry.halfRoadRight),
// 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다(짝: 파이썬).
carriageway_standard_width_m: round4(group.road_width_m),
widening_left_m: round4(geometry.halfRoadLeft - geometry.halfRoad),
widening_right_m: round4(geometry.halfRoadRight - geometry.halfRoad),
cross_slope_pct: round4(crossSlopePct),
ditch: ditchSpec,
ditch_enabled: geometry.hasDitch,
paved,
road_edges: {
left: {
offset_m: round4(geometry.leftExtent),
elevation_m: round4(geometry.roadZ(geometry.leftExtent)),
},
right: {
offset_m: round4(-geometry.rightExtent),
elevation_m: round4(geometry.roadZ(-geometry.rightExtent)),
},
},
carriageway_edges: {
left: {
offset_m: round4(geometry.halfRoadLeft),
elevation_m: round4(geometry.roadZ(geometry.halfRoadLeft)),
},
right: {
offset_m: round4(-geometry.halfRoadRight),
elevation_m: round4(geometry.roadZ(-geometry.halfRoadRight)),
},
},
design_elevation_m: round4(centerElevation),
cut_area_m2: round4(cutArea),
cut_soil_area_m2: round4(cutSoilArea),
cut_rock_area_m2: round4(cutRockArea),
cut_rock_kind: cutRockKind,
fill_area_m2: round4(fillArea),
slope_unclosed: slopeUnclosed,
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
ditch_area_m2: round4(ditchArea),
design_line: designLine,
cut_slope_segments: geometry.cutSlopeSegments(),
};
if (drop > 0) result.surface_drop_m = round4(drop);
if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m);
if (presetKey === "rock" && rockBoundaryOffsetM !== null) {
result.rock_boundary_offset_m = round4(rockBoundaryOffsetM);
}
return result;
}
/** 짝: 파이썬 `round(offset, 6)` — 병합 격자 중복 제거 기준. */
function round6(value: number): number {
return Math.round(value * 1e6) / 1e6;
}