From dc52d55988f7d1bdc9fbdacb6ed44ada681998c9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 12:35:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EC=9D=B4=20=EB=A7=8C=EB=93=A0=20=ED=8F=90=ED=9A=8C=EB=A1=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=88=C2=B7=EC=84=B1=ED=86=A0=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=20=EC=82=B0=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-09-06 사용자 확정 — 구조물 **자체 면적을 빼는 것이 아니라**, 지반선과 실제로 그려지는 설계선이 이루는 폐회로의 넓이다. 기존 계산은 표준 설계선만 보아 구조물이 있는지조차 몰랐다. - 그리는 쪽이 이미 만든 트림(`designTrim`)을 그대로 받아 「실제로 그려지는 설계선」을 세운다 — 트림 안쪽은 설계선, 바깥은 구조물 성토부선, 그것도 없으면 원지반(면적 0). 그림과 면적이 같은 입력을 쓰므로 둘이 갈리지 않는다. - 벽면은 **수직**이라 트림 경계 바로 바깥에 점을 하나 더 찍는다. 안 넣으면 사다리꼴이 단차를 비스듬히 이어 붙여 없는 면적이 생겼다(실측 6.25㎡ → 7.5㎡). - 절토는 암 경계선이 있으면 기존 규칙대로 토사/암으로 나눈다. - 확인(node) — 전구간 100㎡ · 벽에서 끊음 6.25㎡ · 구조물선 포함 20㎡. 한계: 서버는 아직 구조물 기하를 몰라 이 보정이 **브라우저 계산에만** 실린다. 저장·확정 뒤 서버 재계산에서는 표준 값으로 돌아간다 — 서버 반영은 남은 작업(계획서 3-2). Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_View.ts | 54 ++++-- .../common_util_cross_structure_areas.ts | 156 ++++++++++++++++++ 2 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 common_util/common_util_cross_structure_areas.ts diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index f3d3f42c..0931fa6f 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -33,6 +33,7 @@ import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; +import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas"; import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { @@ -437,17 +438,16 @@ export function createCrossSectionCard( if (highlightRevet === "own") setOwnActive(true); } } - appendCrossDesignOverlay( - plotLayer, - section.design, - x, - toDisplayY, - drawSamples, + const designTrim = culvertLayout?.designTrim ?? - fordLayout?.designTrim ?? - boxLayout?.designTrim ?? - ownDesignTrim, - ); + fordLayout?.designTrim ?? + boxLayout?.designTrim ?? + ownDesignTrim; + // 구조물이 선 측점의 절·성토 면적 — **그려지는 폐회로**의 넓이로 고쳐 든다 + // (2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 것이 아니다). 그림과 면적이 + // 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다. + applyStructureAreas(section, designTrim); + appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim); // 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님). if (rockBoundary && section.design.geometry_preset === "rock") { appendRockBoundaryOverlay( @@ -697,3 +697,37 @@ export function createCrossSectionCard( appendCardFooter(card, section, rockBoundary); return card; } + +/** + * 구조물이 만든 폐회로 면적을 측점 설계에 반영한다(2026-09-06 사용자 확정). + * + * 표준 설계선만 보는 기본 계산은 구조물이 있는지 모른다 — 기슭막이·세월교·BOX가 서면 + * 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그리므로 폐회로가 달라진다. + * 트림이 없으면(구조물 없는 측점) 아무것도 하지 않는다. + * + * ⚠ 서버는 아직 구조물 기하를 모른다 — 저장·확정 뒤 서버가 다시 계산하면 표준 값으로 + * 돌아간다(계획서 3-2 남은 몫). + */ +function applyStructureAreas(section: CrossSection, trim: DesignTrim | undefined | null): void { + const design = section.design; + if (!trim || !design || !Array.isArray(design.design_line) || design.design_line.length < 2) { + return; + } + const ground = section.samples + .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") + .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const areas = computeStructureAreas({ + designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, + ground, + trim, + rockBoundaryOffsetM: + typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, + }); + if (!areas) return; + design.cut_area_m2 = Number(areas.cutAreaM2.toFixed(4)); + design.fill_area_m2 = Number(areas.fillAreaM2.toFixed(4)); + if (typeof design.cut_soil_area_m2 === "number") { + design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4)); + } +} diff --git a/common_util/common_util_cross_structure_areas.ts b/common_util/common_util_cross_structure_areas.ts new file mode 100644 index 00000000..8760c6c1 --- /dev/null +++ b/common_util/common_util_cross_structure_areas.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * 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(); + 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, + }; +}