2026-09-06 사용자 확정 — 구조물 **자체 면적을 빼는 것이 아니라**, 지반선과 실제로 그려지는 설계선이 이루는 폐회로의 넓이다. 기존 계산은 표준 설계선만 보아 구조물이 있는지조차 몰랐다. - 그리는 쪽이 이미 만든 트림(`designTrim`)을 그대로 받아 「실제로 그려지는 설계선」을 세운다 — 트림 안쪽은 설계선, 바깥은 구조물 성토부선, 그것도 없으면 원지반(면적 0). 그림과 면적이 같은 입력을 쓰므로 둘이 갈리지 않는다. - 벽면은 **수직**이라 트림 경계 바로 바깥에 점을 하나 더 찍는다. 안 넣으면 사다리꼴이 단차를 비스듬히 이어 붙여 없는 면적이 생겼다(실측 6.25㎡ → 7.5㎡). - 절토는 암 경계선이 있으면 기존 규칙대로 토사/암으로 나눈다. - 확인(node) — 전구간 100㎡ · 벽에서 끊음 6.25㎡ · 구조물선 포함 20㎡. 한계: 서버는 아직 구조물 기하를 몰라 이 보정이 **브라우저 계산에만** 실린다. 저장·확정 뒤 서버 재계산에서는 표준 값으로 돌아간다 — 서버 반영은 남은 작업(계획서 3-2). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
7.2 KiB
TypeScript
157 lines
7.2 KiB
TypeScript
/* =============================================================================
|
|
* common_util_cross_structure_areas.ts
|
|
* 구조물이 선 자리의 **절·성토 면적** — 지반선과 「실제로 그려지는 설계선」이 이루는
|
|
* 폐회로의 넓이다(2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 것이 아니다).
|
|
*
|
|
* 왜 따로 있나 — 기본 면적 계산(`common_util_cross_design.ts`)은 지반선과 **표준 설계선**의
|
|
* 차이만 적분한다. 그 계산은 구조물이 있는지조차 모른다. 그런데 기슭막이·세월교·BOX암거가
|
|
* 서면 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그린다 — 화면에 그려지는
|
|
* 폐회로가 달라지므로 면적도 달라져야 한다.
|
|
*
|
|
* 여기서는 **그리는 쪽이 이미 만든 트림 값**(`designTrim`)을 그대로 받는다. 화면과 면적이
|
|
* 같은 입력을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다.
|
|
*
|
|
* ⚠ 파이썬 짝이 아직 없다 — 서버는 구조물 기하를 모른다(계획서 3-2). 그래서 이 보정은
|
|
* **브라우저 계산에만** 실린다. 저장·확정 뒤 서버가 다시 계산하면 표준 설계선 값으로
|
|
* 돌아간다. 서버 쪽 반영은 별도 작업이다.
|
|
* ========================================================================== */
|
|
|
|
import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
|
|
|
|
/** 트림 경계 바로 바깥에 찍는 점의 간격(m) — 벽면을 수직으로 만들기 위한 값. */
|
|
const BOUNDARY_EPS_M = 1e-6;
|
|
|
|
/** 그리는 쪽이 넘겨 주는 트림 — 배수관·세월교·BOX 세트가 같은 모양으로 낸다. */
|
|
export interface DesignTrim {
|
|
minOffset: number;
|
|
maxOffset: number;
|
|
minElevation?: number;
|
|
maxElevation?: number;
|
|
/** 트림 바깥(−offset 쪽)을 대신 그리는 폴리라인 — 노견에서 벽까지의 성토부선. */
|
|
minSlope?: { points: Array<{ offset: number; elevation: number }> };
|
|
/** 트림 바깥(+offset 쪽) 폴리라인. */
|
|
maxSlope?: { points: Array<{ offset: number; elevation: number }> };
|
|
}
|
|
|
|
export interface StructureAreaInput {
|
|
/** 표준 설계선(측점 저장분) — 트림 안쪽은 이 선을 그대로 쓴다. */
|
|
designLine: Array<{ offset_m: number; elevation_m: number }>;
|
|
/** 지반선 샘플(유효한 것만, 오프셋 오름차순). */
|
|
ground: Array<{ offset: number; elevation: number }>;
|
|
trim: DesignTrim;
|
|
/** 암반 경계선 오프셋(m, 절대값). 있으면 절토를 토사/암으로 나눈다. */
|
|
rockBoundaryOffsetM?: number | null;
|
|
}
|
|
|
|
export interface StructureAreaResult {
|
|
cutAreaM2: number;
|
|
fillAreaM2: number;
|
|
cutSoilAreaM2: number;
|
|
cutRockAreaM2: number;
|
|
}
|
|
|
|
/** 오프셋 오름차순 폴리라인의 선형 보간기. 범위 밖은 끝값을 문다. */
|
|
function interpolator(
|
|
points: Array<{ offset: number; elevation: number }>,
|
|
): ((offset: number) => number) | null {
|
|
if (points.length === 0) return null;
|
|
const sorted = [...points].sort((a, b) => a.offset - b.offset);
|
|
return (offset: number): number => {
|
|
if (offset <= sorted[0].offset) return sorted[0].elevation;
|
|
const last = sorted[sorted.length - 1];
|
|
if (offset >= last.offset) return last.elevation;
|
|
for (let index = 1; index < sorted.length; index += 1) {
|
|
const right = sorted[index];
|
|
if (offset > right.offset) continue;
|
|
const left = sorted[index - 1];
|
|
const span = right.offset - left.offset;
|
|
const ratio = span > 1e-9 ? (offset - left.offset) / span : 0;
|
|
return left.elevation + (right.elevation - left.elevation) * ratio;
|
|
}
|
|
return last.elevation;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 구조물이 선 뒤의 절·성토 면적. 트림 바깥은 구조물이 그리는 폴리라인을 따르고, 그 선이
|
|
* 없으면 트림 경계 표고에서 끊어 **지반선에 붙인다**(그 바깥은 손대지 않은 원지반이라
|
|
* 면적이 0이 된다).
|
|
*/
|
|
export function computeStructureAreas(input: StructureAreaInput): StructureAreaResult | null {
|
|
const design = interpolator(
|
|
input.designLine.map((point) => ({ offset: point.offset_m, elevation: point.elevation_m })),
|
|
);
|
|
const ground = interpolator(input.ground);
|
|
if (!design || !ground || input.ground.length < 2) return null;
|
|
|
|
const { trim } = input;
|
|
const minSlope = trim.minSlope?.points?.length ? interpolator(trim.minSlope.points) : null;
|
|
const maxSlope = trim.maxSlope?.points?.length ? interpolator(trim.maxSlope.points) : null;
|
|
const minSlopeRange = trim.minSlope?.points?.length
|
|
? [
|
|
Math.min(...trim.minSlope.points.map((p) => p.offset)),
|
|
Math.max(...trim.minSlope.points.map((p) => p.offset)),
|
|
]
|
|
: null;
|
|
const maxSlopeRange = trim.maxSlope?.points?.length
|
|
? [
|
|
Math.min(...trim.maxSlope.points.map((p) => p.offset)),
|
|
Math.max(...trim.maxSlope.points.map((p) => p.offset)),
|
|
]
|
|
: null;
|
|
|
|
/** 이 오프셋에서 **실제로 그려지는** 설계선 표고. 폐회로의 위쪽 경계다. */
|
|
const drawnZ = (offset: number): number => {
|
|
if (offset < trim.minOffset) {
|
|
if (minSlope && minSlopeRange && offset >= minSlopeRange[0] && offset <= minSlopeRange[1]) {
|
|
return minSlope(offset);
|
|
}
|
|
// 구조물이 그리는 선이 닿지 않는 바깥 — 원지반 그대로(면적 0).
|
|
return ground(offset);
|
|
}
|
|
if (offset > trim.maxOffset) {
|
|
if (maxSlope && maxSlopeRange && offset >= maxSlopeRange[0] && offset <= maxSlopeRange[1]) {
|
|
return maxSlope(offset);
|
|
}
|
|
return ground(offset);
|
|
}
|
|
return design(offset);
|
|
};
|
|
|
|
// 적분 격자 = 지반 샘플 ∪ 설계선 꼭짓점 ∪ 트림 경계 ∪ 구조물 폴리라인 꼭짓점.
|
|
// 꺾이는 자리를 모두 넣어야 사다리꼴 적분이 모서리를 잘라먹지 않는다.
|
|
const lo = input.ground[0].offset;
|
|
const hi = input.ground[input.ground.length - 1].offset;
|
|
const grid = new Set<number>();
|
|
const add = (offset: number): void => {
|
|
if (offset >= lo && offset <= hi) grid.add(Math.round(offset * 1e6) / 1e6);
|
|
};
|
|
input.ground.forEach((sample) => add(sample.offset));
|
|
input.designLine.forEach((point) => add(point.offset_m));
|
|
add(trim.minOffset);
|
|
add(trim.maxOffset);
|
|
// 트림 경계에서 그려지는 선은 **수직으로 끊긴다**(벽면). 경계 바로 바깥 점을 함께 넣어
|
|
// 사다리꼴이 그 단차를 비스듬히 이어 붙이지 않게 한다 — 안 넣으면 격자 한 칸만큼
|
|
// 없는 면적이 생긴다(실측: 벽 안쪽 6.25㎡ 가 7.5㎡ 로 잡혔다).
|
|
add(trim.minOffset - BOUNDARY_EPS_M);
|
|
add(trim.maxOffset + BOUNDARY_EPS_M);
|
|
trim.minSlope?.points.forEach((point) => add(point.offset));
|
|
trim.maxSlope?.points.forEach((point) => add(point.offset));
|
|
const offsets = [...grid].sort((a, b) => a - b);
|
|
if (offsets.length < 2) return null;
|
|
|
|
const diffs = offsets.map((offset) => ground(offset) - drawnZ(offset));
|
|
const [cutArea, fillArea] = trapezoidAreas(offsets, diffs);
|
|
const rock = input.rockBoundaryOffsetM;
|
|
const [cutSoil, cutRock] =
|
|
typeof rock === "number" && Number.isFinite(rock)
|
|
? splitCutAreas(offsets, diffs, Math.abs(rock))
|
|
: [cutArea, 0];
|
|
return {
|
|
cutAreaM2: cutArea,
|
|
fillAreaM2: fillArea,
|
|
cutSoilAreaM2: cutSoil,
|
|
cutRockAreaM2: cutRock,
|
|
};
|
|
}
|