fix(B06): 유토곡선 포물선 보간 + 잉여·부족 장거리 상쇄

참고 도면(기번3, 측점별 누가토량 81점 + balloon 43개)과 대조해 오류 2건 확인·수정.

[1] 측점 사이는 직선이 아니라 포물선이다. 단면적이 선형으로 변하면 그 적분인
누가토량은 2차식이 된다. 도면에서 측점간격 20m 구간의 반종거가 20/√2 = 14.14m로
12번 반복해 찍히는 것이 근거(직선이면 10m). 직선으로 재던 평균운반거리가 약 40%
짧게 나와 20m 경계 판정이 어긋나고 있었다.

MassHaulPoint에 다짐환산 순단면적을 실어 구간마다 V(t) = V0 + a0t + (a1-a0)t²/(2Δ)를
세운다. V(Δ) = V0 + (a0+a1)Δ/2라 곡선 자체는 그대로고 현 길이만 바뀐다.

두 단면적의 평균이 구간 평균과 어긋나면 포물선이 끝점을 벗어나 수평선 교점을 못 찾고
블록이 통째로 늘어난다(도면 곡선 검증에서 성토 1455㎥가 한 블록에 묻혔다). 두 값을
같은 양만큼 옮겨 끝값을 강제로 맞춘다. 곡률은 보존되므로 포물선 모양은 그대로다.

[2] 남은 사토와 토취를 거리 가까운 짝부터 맞물려 장거리 운반으로 바꾼다. 도면 곡선은
0선 아래로 한 번도 내려가지 않는데 기존 로직은 토취 1435㎥를 만들어 냈다. 도면의
덤프 L=935/1008m가 이 장거리 운반이고, 배분 3원칙 ①(거리 최소) ③(모아서 운반)이다.

- 사토 표기에 측점(M.N) 추가 - 도면은 사토에 운반거리를 안 매긴다
- 검산 칩: 운반(띠) + 장거리 운반 + 토취 = 총 성토량
- 장거리 운반선은 1점쇄선 + 화살촉 (네 번째 선타입)
- 700줄 초과로 곡선 기하를 _UI_MassHaul_Curve.ts로 분리

도면 곡선 재현: 토취 1435 -> 0, 사토 2606 -> 1171(= 최종 누가토량),
운반계 오차 -10.1% -> -0.80%, 종무대 L 0.2~13.2 -> 10.51~18.01m(도면 8.39~15.98).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 17:19:48 +09:00
co-authored by Claude Opus 5
parent c490e2ff45
commit 55dc5629e2
7 changed files with 405 additions and 90 deletions
@@ -72,6 +72,15 @@ export interface MassHaulPoint {
cut_compacted_m3: number;
/** 구간 성토량(다짐상태 = 설계 물량). */
fill_m3: number;
/**
* 이 측점의 **순단면적**(㎡, 다짐환산) = 절토환산단면적 − 성토단면적.
*
* 유토곡선은 측점 사이에서 직선이 아니라 **포물선**이다 — 단면적이 선형으로 변하면
* 그 적분인 누가토량은 2차식이 된다. 평균운반거리(반종거 현)를 직선으로 재면 참고 도면 대비
* 약 40% 짧게 나와 장비 경계 판정이 어긋난다(도면의 20m 구간 반종거가 20/√2 = 14.14m).
* 이 값이 있어야 `_UI_MassHaul_Balance`가 구간마다 2차식을 세울 수 있다.
*/
net_area_m2: number;
}
export interface MassHaulResult {
@@ -176,6 +185,18 @@ function splitCut(design: CrossDesign | undefined): {
};
}
/**
* 측점 하나의 순단면적(다짐환산 절토 − 성토). 구간 토량 `(a₀ + a₁)Δ/2`가 평균단면법과
* 정확히 같으므로, 이 값으로 세운 2차식은 지금 곡선을 그대로 통과한다.
*/
function sampleNetArea(sample: AreaSample, conversion: EarthworkConversion): number {
const soil = sample.cut_soil_area_m2 * factorFor(conversion, "soil");
const rock = sample.rock_kind
? sample.cut_rock_area_m2 * factorFor(conversion, sample.rock_kind)
: 0;
return soil + rock - sample.fill_area_m2;
}
/**
* 단면적 시퀀스를 누가토량으로 적분한다.
*
@@ -198,6 +219,7 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass
cut_br_m3: 0,
cut_compacted_m3: 0,
fill_m3: 0,
net_area_m2: sampleNetArea(samples[0], conversion),
},
];
const cutNatural = emptyVolumes();
@@ -256,6 +278,7 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass
cut_br_m3: segmentCutBlasting,
cut_compacted_m3: segmentCut,
fill_m3: segmentFill,
net_area_m2: sampleNetArea(current, conversion),
});
}
@@ -413,6 +436,7 @@ export function massHaulPayload(
cut_rock_m3: round(point.cut_rock_m3),
cut_rr_m3: round(point.cut_rr_m3),
cut_br_m3: round(point.cut_br_m3),
net_area_m2: round(point.net_area_m2),
fill_m3: round(point.fill_m3),
})),
};
@@ -49,6 +49,12 @@
import type { EarthworkConversion, GroundType } from "./B06_wf3_ProfileCross_Api_Fetch";
import type { MassHaulPoint, MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul";
import {
crossFrom,
EPSILON,
extremaIndices,
pruneExtrema,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Curve";
/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */
export interface HaulEquipmentLimit {
@@ -121,6 +127,29 @@ export interface HaulResidual {
level_to_m3: number;
}
/**
* 떨어진 잉여 구간 → 부족 구간 **장거리 운반**. 참고 도면의 덤프 운반 중 L이 900~1000m대인
* 것들이 이것으로, 한 산의 현으로는 설명되지 않는다(노선 연장보다 긴 현은 없다).
*
* 배분 3원칙 ③ "토량을 모아 한 가지 방법으로 운반"이 바로 이 항목이다. 이것이 없으면
* 절토가 우세한 노선인데도 국지 부족이 그대로 토취로 계상돼, 사지 않아도 될 흙을 사게 된다.
*/
export interface HaulTransfer {
index: number;
/** 퍼오는 자리 / 채우는 자리(누가거리 m, 각 잔량 구간의 중앙). */
from_m: number;
to_m: number;
volume_m3: number;
/** 두 자리 사이 거리 = 이 운반의 `L`. */
haul_distance_m: number;
/** 연결 수평선을 그릴 높이(누가토량 ㎥). */
level_m3: number;
equipment: string | null;
ea_m3: number;
rr_m3: number;
br_m3: number;
}
/** 계단형 평형선 한 칸. 렌더러가 칸 사이를 수직선으로 이어 계단을 만든다. */
export interface BalanceStep {
from_m: number;
@@ -131,15 +160,22 @@ export interface BalanceStep {
export interface HaulPlan {
blocks: HaulBlock[];
residuals: HaulResidual[];
transfers: HaulTransfer[];
steps: BalanceStep[];
spoil_m3: number;
borrow_m3: number;
/** 운반 블록 토량 합계(㎥) — 실제로 장비가 옮기는 양. */
/** 블록 안에서 옮기는 양(㎥). */
hauled_m3: number;
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
transferred_m3: number;
/**
* 총 성토량(㎥) — **검산 기준값**. 성토는 전부 운반으로 채워지므로
* `hauled_m3 + transferred_m3`가 이 값과 같아야 한다.
* 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다.
*/
fill_total_m3: number;
}
const EPSILON = 1e-9;
/**
* 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 balloon이
* 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
@@ -156,87 +192,6 @@ function factorFor(conversion: EarthworkConversion, ground: GroundType): number
return Number.isFinite(factor) && factor > 0 ? factor : 1;
}
/** 곡선을 선형보간해 임의 위치의 누가토량을 읽는다(양 끝은 클램프). */
function valueAt(points: MassHaulPoint[], x: number): number {
const last = points[points.length - 1];
if (x <= points[0].chainage_m) return points[0].cumulative_volume_m3;
if (x >= last.chainage_m) return last.cumulative_volume_m3;
for (let index = 1; index < points.length; index += 1) {
const a = points[index - 1];
const b = points[index];
if (x > b.chainage_m) continue;
const span = b.chainage_m - a.chainage_m;
if (span <= 0) return b.cumulative_volume_m3;
const ratio = (x - a.chainage_m) / span;
return a.cumulative_volume_m3 + (b.cumulative_volume_m3 - a.cumulative_volume_m3) * ratio;
}
return last.cumulative_volume_m3;
}
/**
* `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null.
* 평형선·경계현·반종거 수평선의 교점이 전부 이 함수 하나로 나온다.
*/
function crossFrom(
points: MassHaulPoint[],
fromX: number,
toX: number,
level: number,
): number | null {
if (!(toX > fromX)) return null;
for (let index = 1; index < points.length; index += 1) {
const x0 = Math.max(points[index - 1].chainage_m, fromX);
const x1 = Math.min(points[index].chainage_m, toX);
if (!(x1 > x0)) continue;
const y0 = valueAt(points, x0);
const y1 = valueAt(points, x1);
if ((y0 - level) * (y1 - level) > 0) continue;
if (Math.abs(y1 - y0) < EPSILON) return x0;
return x0 + ((level - y0) / (y1 - y0)) * (x1 - x0);
}
return null;
}
/** 곡선의 극값 인덱스 수열(양 끝점 포함). 기울기 부호가 바뀌는 자리가 극값이다. */
function extremaIndices(points: MassHaulPoint[]): number[] {
const list = [0];
let sign = 0;
for (let index = 1; index < points.length; index += 1) {
const delta = points[index].cumulative_volume_m3 - points[index - 1].cumulative_volume_m3;
if (Math.abs(delta) < EPSILON) continue;
const next = delta > 0 ? 1 : -1;
if (sign !== 0 && next !== sign && list[list.length - 1] !== index - 1) list.push(index - 1);
sign = next;
}
if (list[list.length - 1] !== points.length - 1) list.push(points.length - 1);
return list;
}
/**
* 잔진동 제거. 진폭이 `minSwing` 미만인 극값은 **짝이 되는 이웃과 함께** 지운다 —
* 하나만 지우면 극대·극소 교대가 깨져 뒤 계산이 방향을 잃는다. 양 끝점은 남긴다.
*/
function pruneExtrema(points: MassHaulPoint[], indices: number[], minSwing: number): number[] {
const valueOf = (index: number): number => points[index].cumulative_volume_m3;
let list = indices.slice();
let changed = true;
while (changed && list.length > 2) {
changed = false;
for (let i = 1; i < list.length - 1; i += 1) {
const left = Math.abs(valueOf(list[i]) - valueOf(list[i - 1]));
const right = Math.abs(valueOf(list[i]) - valueOf(list[i + 1]));
if (Math.min(left, right) >= minSwing) continue;
const partner = left <= right ? i - 1 : i + 1;
const drop = new Set<number>([i]);
if (partner > 0 && partner < list.length - 1) drop.add(partner);
list = list.filter((_, position) => !drop.has(position));
changed = true;
break;
}
}
return list;
}
interface CutMix {
ea: number;
rr: number;
@@ -290,6 +245,20 @@ function sortedLimits(limits: HaulEquipmentLimit[] | undefined): HaulEquipmentLi
});
}
/**
* 운반거리에 맞는 장비를 고른다. 띠 분할은 경계현으로 하지만 **장거리 운반**은 거리가
* 먼저 정해지므로 이렇게 거꾸로 고른다. 경계 정의처는 config 한 곳뿐이라, 값을 못 받았으면
* 프론트에 사본을 두지 않고 그냥 비운다.
*/
function pickEquipment(limits: HaulEquipmentLimit[] | undefined, distanceM: number): string | null {
if (!limits?.length) return null;
for (const limit of sortedLimits(limits)) {
if (limit.max_distance_m === null || distanceM <= limit.max_distance_m)
return limit.key || null;
}
return null;
}
/** 한 블록을 장비 띠로 가르는 데 필요한 기하 계산 묶음. */
interface BlockGeometry {
/** `ratio`(0 = 평형선, 1 = 정점)에 해당하는 누가토량 높이. */
@@ -545,22 +514,80 @@ export function computeHaulPlan(
}
if (!blocks.length && !residuals.length) return null;
const transfers = settleResiduals(points, residuals, result.conversion, limits);
const settled = residuals.filter((residual) => residual.volume_m3 > EPSILON);
settled.forEach((residual, index) => {
residual.index = index + 1;
});
let spoil = 0;
let borrow = 0;
for (const residual of residuals) {
for (const residual of settled) {
if (residual.kind === "spoil") spoil += residual.volume_m3;
else borrow += residual.volume_m3;
}
let fillTotal = 0;
for (const point of points) fillTotal += point.fill_m3;
return {
blocks,
residuals,
residuals: settled,
transfers,
steps,
spoil_m3: spoil,
borrow_m3: borrow,
hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0),
transferred_m3: transfers.reduce((sum, entry) => sum + entry.volume_m3, 0),
fill_total_m3: fillTotal,
};
}
/**
* 남은 잉여(사토)와 부족(토취)을 **거리가 가까운 짝부터** 맞물려 장거리 운반으로 바꾼다
* (배분 3원칙 ① 운반거리 최소). 맞물린 만큼 두 잔량에서 덜어 내고, 끝내 남는 쪽만
* 진짜 사토/토취로 남는다. 잉여가 우세한 노선이면 토취가 전부 사라진다.
*
* `residuals`를 그 자리에서 깎으므로 호출한 쪽은 0이 된 항목을 걸러야 한다.
*/
function settleResiduals(
points: MassHaulPoint[],
residuals: HaulResidual[],
conversion: EarthworkConversion,
limits: HaulEquipmentLimit[] | undefined,
): HaulTransfer[] {
const center = (residual: HaulResidual): number => (residual.from_m + residual.to_m) / 2;
const level = (residual: HaulResidual): number =>
(residual.level_from_m3 + residual.level_to_m3) / 2;
const transfers: HaulTransfer[] = [];
const pairs: Array<{ spoil: HaulResidual; borrow: HaulResidual; distance: number }> = [];
for (const spoil of residuals.filter((entry) => entry.kind === "spoil")) {
for (const borrow of residuals.filter((entry) => entry.kind === "borrow")) {
pairs.push({ spoil, borrow, distance: Math.abs(center(spoil) - center(borrow)) });
}
}
pairs.sort((a, b) => a.distance - b.distance);
for (const pair of pairs) {
const volume = Math.min(pair.spoil.volume_m3, pair.borrow.volume_m3);
if (!(volume > EPSILON)) continue;
pair.spoil.volume_m3 -= volume;
pair.borrow.volume_m3 -= volume;
// 퍼오는 쪽이 절토 구간이므로 지반유형은 사토 구간에서 읽는다.
const mix = cutMix(points, pair.spoil.from_m, pair.spoil.to_m, conversion);
transfers.push({
index: transfers.length + 1,
from_m: center(pair.spoil),
to_m: center(pair.borrow),
volume_m3: volume,
haul_distance_m: pair.distance,
level_m3: (level(pair.spoil) + level(pair.borrow)) / 2,
equipment: pickEquipment(limits, pair.distance),
...apportion(mix, volume),
});
}
return transfers;
}
/** 확정 저장·B08 인계용 직렬화. 값은 소수 둘째 자리에서 끊는다. */
export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
const round = (value: number): number => Math.round(value * 100) / 100;
@@ -568,6 +595,20 @@ export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
spoil_m3: round(plan.spoil_m3),
borrow_m3: round(plan.borrow_m3),
hauled_m3: round(plan.hauled_m3),
transferred_m3: round(plan.transferred_m3),
fill_total_m3: round(plan.fill_total_m3),
// 떨어진 구간끼리의 장거리 운반 — B08 내역서가 별도 운반 항목으로 세운다.
transfers: plan.transfers.map((transfer) => ({
index: transfer.index,
from_m: round(transfer.from_m),
to_m: round(transfer.to_m),
volume_m3: round(transfer.volume_m3),
haul_distance_m: round(transfer.haul_distance_m),
equipment: transfer.equipment,
ea_m3: round(transfer.ea_m3),
rr_m3: round(transfer.rr_m3),
br_m3: round(transfer.br_m3),
})),
blocks: plan.blocks.map((block) => ({
index: block.index,
from_m: round(block.from_m),
@@ -20,9 +20,10 @@ import type {
HaulBlock,
HaulPlan,
HaulResidual,
HaulTransfer,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
import type { LocaleKey } from "@ui/ui_template_locale";
import { L, svgElement, svgText } from "./B06_wf3_ProfileCross_UI_Section_Common";
import { L, stationLabel, svgElement, svgText } from "./B06_wf3_ProfileCross_UI_Section_Common";
/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */
export interface BalanceLayerBox {
@@ -37,6 +38,8 @@ export interface BalanceLayerBox {
* balloon을 곡선과 겹치지 않는 자리에 놓기 위해 유토곡선 렌더러가 넘겨준다.
*/
curveYAt?: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
/** 사토·토취 발생 위치를 측점으로 적기 위한 측점 간격(m). */
stationInterval?: number;
}
const EQUIPMENT_LABEL: Record<string, LocaleKey> = {
@@ -471,7 +474,13 @@ function appendResidual(
);
const y1 = box.y(residual.level_from_m3);
const y2 = box.y(residual.level_to_m3);
const label = `${L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage")} ${compactVolume(residual.volume_m3)}`;
// 도면은 사토 balloon에 거리(L)가 아니라 **측점**(M.N)을 적는다 — 사토는 운반이 아니라
// 그 자리 처리라 운반거리를 매기지 않기 때문이다(2026-08-02 도면 분석).
const at = (residual.from_m + residual.to_m) / 2;
const station = box.stationInterval ? ` · ${stationLabel(at, box.stationInterval)}` : "";
const label =
`${L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage")} ` +
`${compactVolume(residual.volume_m3)}${station}`;
const marker = svgElement("g", {
class: `b06-balance__residual b06-balance__residual--${residual.kind}`,
});
@@ -503,6 +512,53 @@ function appendResidual(
group.append(marker);
}
/**
* 떨어진 잉여 → 부족 **장거리 운반**. 한 산의 현이 아니라 두 지역을 잇는 선이라
* 1점쇄선 + 양 끝 화살촉으로 구분한다. 길이가 곧 그 운반의 `L`이다.
*/
function appendTransfer(
group: SVGGElement,
transfer: HaulTransfer,
box: BalanceLayerBox,
compact: boolean,
): void {
const fromX = box.x(transfer.from_m);
const toX = box.x(transfer.to_m);
const y = Math.min(Math.max(box.y(transfer.level_m3), box.top + 4), box.bottom - 4);
const marker = svgElement("g", { class: "b06-balance__transfer" });
const title = svgElement("title");
const equipment = equipmentLabel(transfer.equipment);
title.textContent =
`${L("B06_MassHaul_Transfer")} ${transfer.index} · ${equipment || "-"} · ` +
`${L("B06_MassHaul_HaulVolume")} ${compactVolume(transfer.volume_m3)}㎥ · ` +
`${L("B06_MassHaul_HaulDistance")} ${compactDistance(transfer.haul_distance_m)}m · ` +
`EA ${compactVolume(transfer.ea_m3)} / RR ${compactVolume(transfer.rr_m3)} / BR ${compactVolume(transfer.br_m3)}`;
const tip = toX >= fromX ? toX : toX;
const back = toX >= fromX ? toX - 7 : toX + 7;
marker.append(
title,
svgElement("line", { x1: fromX, y1: y, x2: toX, y2: y, class: "b06-balance__transfer-line" }),
svgElement("polygon", {
points: `${tip},${y} ${back},${y - 4} ${back},${y + 4}`,
class: "b06-balance__transfer-head",
}),
);
if (!compact) {
marker.append(
svgText(
`${compactVolume(transfer.volume_m3)}㎥ · ${compactDistance(transfer.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`,
{
x: (fromX + toX) / 2,
y: y - 4,
"text-anchor": "middle",
class: "b06-balance__transfer-text",
},
),
);
}
group.append(marker);
}
/**
* 토량 분배 레이어를 유토곡선 SVG에 얹는다. 곡선·측점선 위, 선택 말풍선 아래에 놓아야
* 읽는 순서가 맞으므로 호출 위치를 옮기지 말 것.
@@ -515,6 +571,7 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
appendBalanceLine(group, plan, box);
const compact = box.bottom - box.top < 110;
for (const transfer of plan.transfers) appendTransfer(group, transfer, box, compact);
for (const residual of plan.residuals) appendResidual(group, residual, box, compact);
const faces: SVGGElement[] = [];
@@ -560,8 +617,25 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
export function haulPlanChips(plan: HaulPlan | null): Array<[string, string]> {
if (!plan) return [];
const bands = plan.blocks.reduce((sum, block) => sum + block.bands.length, 0);
return [
[L("B06_MassHaul_HaulTotal"), `${compactVolume(plan.hauled_m3)}`],
const moved = plan.hauled_m3 + plan.transferred_m3;
// 검산 — 성토는 **절토를 옮겨 온 것 + 사 온 것(토취)**으로만 채워진다:
// 운반(띠) + 장거리 운반 + 토취 = 총 성토량
// 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다
// (그 노선은 절토 우세라 토취가 0이었다).
const gap = moved + plan.borrow_m3 - plan.fill_total_m3;
const ratio = plan.fill_total_m3 > 0 ? Math.abs(gap) / plan.fill_total_m3 : 0;
const chips: Array<[string, string]> = [
[L("B06_MassHaul_HaulTotal"), `${compactVolume(moved)}`],
[L("B06_MassHaul_BlockCount"), `${plan.blocks.length} / ${bands}`],
];
if (plan.transfers.length) {
chips.push([L("B06_MassHaul_Transfer"), `${compactVolume(plan.transferred_m3)}`]);
}
chips.push([
L("B06_MassHaul_Check"),
ratio <= 0.005
? L("B06_MassHaul_Check_Ok")
: `${gap > 0 ? "+" : ""}${compactVolume(Math.abs(gap))}`,
]);
return chips;
}
@@ -0,0 +1,151 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_Curve.ts
* 누가토량 곡선의 기하 — 포물선 보간 · 수평선 교점 · 극값 추출.
*
* 유토곡선은 측점 사이에서 **직선이 아니라 포물선**이다. 측점별 단면적이 선형으로 변하면
* 그 적분인 누가토량은 2차식이 되기 때문이다. 이걸 직선으로 보면 평균운반거리(반종거 현)가
* 참고 도면 대비 약 40% 짧게 나와 장비 경계 판정이 어긋난다 — 도면에서 측점간격 20m 구간의
* 반종거가 `20/√2 = 14.14m`로 12번 반복해 찍히는 것이 근거다(직선이면 10m).
*
* 토량 분배 계산(`_UI_MassHaul_Balance`)이 700줄을 넘겨 곡선 기하만 여기로 떼어냈다.
* ========================================================================== */
import type { MassHaulPoint } from "./B06_wf3_ProfileCross_UI_MassHaul";
export const EPSILON = 1e-9;
/**
* 구간 하나의 2차식 계수. 측점 사이에서 단면적이 선형으로 변하므로 누가토량은
* `V(t) = V₀ + a₀·t + (a₁ a₀)·t² / (2Δ)` (t = 구간 시작으로부터의 거리)
* 이다. `V(Δ) = V₀ + (a₀ + a₁)Δ/2`라 평균단면법 결과와 정확히 같다.
*
* 순단면적이 없는 데이터(옛 payload·종단 기준 개략값)는 구간 평균 단면적을 양 끝에 같이
* 넣어 자동으로 직선으로 떨어진다.
*/
export interface Segment {
x0: number;
span: number;
v0: number;
a0: number;
curvature: number;
}
export function segmentOf(points: MassHaulPoint[], index: number): Segment | null {
const a = points[index - 1];
const b = points[index];
const span = b.chainage_m - a.chainage_m;
if (!(span > 0)) return null;
const average = (b.cumulative_volume_m3 - a.cumulative_volume_m3) / span;
const start = Number.isFinite(a.net_area_m2) ? a.net_area_m2 : average;
const end = Number.isFinite(b.net_area_m2) ? b.net_area_m2 : average;
// 포물선이 **구간 끝값을 정확히 통과하도록** 두 단면적을 같은 양만큼 옮긴다.
// `V(Δ) = V₀ + (a₀+a₁)Δ/2`이므로 평균이 어긋나면 끝값이 어긋나고, 그러면 수평선 교점을
// 못 찾아 블록이 엉뚱하게 길어진다(도면 곡선 검증에서 성토 1455㎥가 한 블록에 묻혔다).
// 곡률 `(a₁−a₀)`는 그대로라 포물선 모양은 보존된다.
const shift = average - (start + end) / 2;
return {
x0: a.chainage_m,
span,
v0: a.cumulative_volume_m3,
a0: start + shift,
curvature: (end - start) / (2 * span),
};
}
export function evaluate(segment: Segment, t: number): number {
return segment.v0 + segment.a0 * t + segment.curvature * t * t;
}
/** 구간 2차식이 `[tLo, tHi]` 안에서 `level`을 처음 지나는 t. 없으면 null. */
function solveSegment(segment: Segment, level: number, tLo: number, tHi: number): number | null {
const c = segment.v0 - level;
const roots: number[] = [];
if (Math.abs(segment.curvature) < 1e-12) {
if (Math.abs(segment.a0) > EPSILON) roots.push(-c / segment.a0);
} else {
const discriminant = segment.a0 * segment.a0 - 4 * segment.curvature * c;
if (discriminant < 0) return null;
const root = Math.sqrt(discriminant);
roots.push((-segment.a0 - root) / (2 * segment.curvature));
roots.push((-segment.a0 + root) / (2 * segment.curvature));
}
const inside = roots
.filter((t) => t >= tLo - 1e-9 && t <= tHi + 1e-9)
.sort((left, right) => left - right);
return inside.length ? Math.min(Math.max(inside[0], tLo), tHi) : null;
}
/**
* `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null.
* 평형선·경계현·반종거 수평선의 교점이 전부 이 함수 하나로 나온다.
*/
export function crossFrom(
points: MassHaulPoint[],
fromX: number,
toX: number,
level: number,
): number | null {
if (!(toX > fromX)) return null;
for (let index = 1; index < points.length; index += 1) {
const segment = segmentOf(points, index);
if (!segment) continue;
const x0 = Math.max(segment.x0, fromX);
const x1 = Math.min(segment.x0 + segment.span, toX);
if (!(x1 > x0)) continue;
const t = solveSegment(segment, level, x0 - segment.x0, x1 - segment.x0);
if (t !== null) return segment.x0 + t;
// 구간 끝에서 부호가 바뀌었는데 2차 근을 못 찾으면(수치 한계) 직선으로 떨어뜨린다.
// 교점을 놓치면 블록 경계가 통째로 밀리므로 여기서 반드시 하나는 돌려줘야 한다.
const y0 = evaluate(segment, x0 - segment.x0);
const y1 = evaluate(segment, x1 - segment.x0);
if ((y0 - level) * (y1 - level) <= 0) {
if (Math.abs(y1 - y0) < EPSILON) return x0;
return x0 + ((level - y0) / (y1 - y0)) * (x1 - x0);
}
}
return null;
}
/** 곡선의 극값 인덱스 수열(양 끝점 포함). 기울기 부호가 바뀌는 자리가 극값이다. */
export function extremaIndices(points: MassHaulPoint[]): number[] {
const list = [0];
let sign = 0;
for (let index = 1; index < points.length; index += 1) {
const delta = points[index].cumulative_volume_m3 - points[index - 1].cumulative_volume_m3;
if (Math.abs(delta) < EPSILON) continue;
const next = delta > 0 ? 1 : -1;
if (sign !== 0 && next !== sign && list[list.length - 1] !== index - 1) list.push(index - 1);
sign = next;
}
if (list[list.length - 1] !== points.length - 1) list.push(points.length - 1);
return list;
}
/**
* 잔진동 제거. 진폭이 `minSwing` 미만인 극값은 **짝이 되는 이웃과 함께** 지운다 —
* 하나만 지우면 극대·극소 교대가 깨져 뒤 계산이 방향을 잃는다. 양 끝점은 남긴다.
*/
export function pruneExtrema(
points: MassHaulPoint[],
indices: number[],
minSwing: number,
): number[] {
const valueOf = (index: number): number => points[index].cumulative_volume_m3;
let list = indices.slice();
let changed = true;
while (changed && list.length > 2) {
changed = false;
for (let i = 1; i < list.length - 1; i += 1) {
const left = Math.abs(valueOf(list[i]) - valueOf(list[i - 1]));
const right = Math.abs(valueOf(list[i]) - valueOf(list[i + 1]));
if (Math.min(left, right) >= minSwing) continue;
const partner = left <= right ? i - 1 : i + 1;
const drop = new Set<number>([i]);
if (partner > 0 && partner < list.length - 1) drop.add(partner);
list = list.filter((_, position) => !drop.has(position));
changed = true;
break;
}
}
return list;
}
@@ -392,6 +392,7 @@ export function createMassHaulChart(
top: MASS_PAD_TOP,
bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt,
stationInterval,
});
}
@@ -263,6 +263,25 @@
font-variant-numeric: tabular-nums;
}
/* 장거리 운반선 — 한 산의 현이 아니라 떨어진 두 지역을 잇는 선이라 **1점쇄선**으로 구분한다
(기선 실선 / 경계현 파선 / 평균운반거리 점선과 겹치지 않는 네 번째 선타입). */
.b06-balance__transfer-line {
stroke: var(--color-accent);
stroke-width: 1.4;
stroke-dasharray: 9 3 2 3;
}
.b06-balance__transfer-head {
fill: var(--color-accent);
stroke: none;
}
.b06-balance__transfer-text {
fill: var(--color-accent);
font-size: 9px;
font-variant-numeric: tabular-nums;
}
/* 사토·토취 단차. 남는 흙(사토)과 모자란 흙(토취)을 색으로 갈라 둔다. */
.b06-balance__step-arrow {
stroke-width: 1.4;
+5
View File
@@ -248,6 +248,11 @@ export const ui_locales_b2 = {
B06_MassHaul_Equip_FreeHaul: ["종무대", "Free haul"],
B06_MassHaul_Equip_Dozer: ["도쟈", "Dozer"],
B06_MassHaul_Equip_Dump: ["덤프", "Dump truck"],
/* 떨어진 잉여 구간 → 부족 구간 장거리 운반. 배분 3원칙 ③(토량을 모아 운반). */
B06_MassHaul_Transfer: ["장거리 운반", "Long haul"],
/* 검산 — 성토는 전부 운반으로 채워지므로 운반 합계 = 총 성토량이어야 한다. */
B06_MassHaul_Check: ["운반·성토 검산", "Haul vs fill check"],
B06_MassHaul_Check_Ok: ["일치", "Balanced"],
/* --- B06 측점 표준횡단 설계 지정 --- */
B06_Design_Ground_Legend: ["지반유형", "Ground type"],