From c19af779e4d6cb67bbbbf25a0202829055309cb0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 19:42:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=ED=9A=A1=EB=8B=A8=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EA=B3=84=EC=82=B0=20TS=20=EB=AF=B8=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=EC=97=90=EC=84=9C=20=EC=A0=88=C2=B7=EC=84=B1=ED=86=A0?= =?UTF-8?q?=20=EB=A9=B4=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-03 사용자 확정). 계획선을 만질 때마다 전 측점 횡단 계산이 서버로 나가 조작 속도를 왕복이 지배했음. - `common_util_cross_design_areas.ts` — `B06_Section_Engine_Areas.py` 미러 (사다리꼴 적분 + 절토 토사/암반 분리). - `common_util_cross_design.ts` — `B06_Section_Engine_Design.py` 미러 (표준횡단 설계선 구성 + 단면적). config 수치는 복제하지 않고 `sections/context.standard_cross_section` 을 입력으로 받음. 두 파일 머리에 짝 파일·회귀 테스트를 가리키는 경고 블록을 둠 — 한쪽만 고치면 두 화면 값이 갈림. 아직 어느 화면도 이 모듈을 부르지 않음(다음 커밋에서 연결). Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_cross_design.ts | 717 ++++++++++++++++++ common_util/common_util_cross_design_areas.ts | 97 +++ 2 files changed, 814 insertions(+) create mode 100644 common_util/common_util_cross_design.ts create mode 100644 common_util/common_util_cross_design_areas.ts diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts new file mode 100644 index 00000000..9c1131a0 --- /dev/null +++ b/common_util/common_util_cross_design.ts @@ -0,0 +1,717 @@ +/* ============================================================================= + * 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 { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; + +/** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */ +const GROUND_TYPE_PRESET: Record = { + 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; +/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ +const MARCH_STEP_M = 0.05; +const KNEE_MAX_M = 200; +const CROSS_MAX_M = 500; + +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; + +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; +} + +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; + cross_slope_pct: number; + ditch: Record; + 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[]; + 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; +} + +interface ResolvedGroup { + road_width_m: number; + shoulder_left_m: number; + shoulder_right_m: number; + ditch_top_width_m: number; + ditch_bottom_width_m: number; + ditch_depth_m: number; + l_ditch_width_m: number; + l_ditch_depth_m: number; + cross_slope_pct: number; + fill_slope_ratio: number; + cut_slope_ratio: number; + pavement_thickness_m: number; +} + +/** + * 짝: `_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), + }; +} + +/** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */ +function sideRole(sectionMode: string): [string, string] { + if (sectionMode === "left_cut") return ["cut", "fill"]; + if (sectionMode === "right_cut") return ["fill", "cut"]; + if (sectionMode === "both_cut") return ["cut", "cut"]; + if (sectionMode === "both_fill") return ["fill", "fill"]; + throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`); +} + +/** 짝: `_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 { + return (offsetM: number): number => { + if (offsetM <= valid[0][0]) return valid[0][1]; + if (offsetM >= valid[valid.length - 1][0]) return valid[valid.length - 1][1]; + for (let index = 1; index < valid.length; index += 1) { + const [x1, z1] = valid[index]; + if (offsetM > x1) continue; + const [x0, z0] = valid[index - 1]; + const span = x1 - x0; + if (span <= 0) return z1; + return z0 + (z1 - z0) * ((offsetM - x0) / span); + } + return valid[valid.length - 1][1]; + }; +} + +/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */ +class SectionGeometry { + halfRoad: number; + leftExtent: number; + rightExtent: number; + zCenter: number; + cutRatio: number; + fillRatio: number; + leftRole: string; + rightRole: string; + ditchSide: string; + soilCutRatio: number; + twoStage: boolean; + ditchType: string; + slopePerOffset: number; + hasDitch: boolean; + ditchPoints: Array<[number, number]> = []; + private groundAt: ((offsetM: number) => number) | null; + private rockOffset: number; + private rockKnee = new Map(); + private cutCross = new Map(); + private fillCross = new Map(); + + constructor(params: { + designElevationM: number; + group: ResolvedGroup; + sectionMode: string; + ditchSide: string; + ditchType: string; + crossSlopePct: number; + groundAt: ((offsetM: number) => number) | null; + soilCutRatio: number | null; + rockBoundaryOffsetM: number | null; + twoStageSlope: boolean; + ditchEnabled: boolean | null; + }) { + const { group } = params; + const halfRoad = group.road_width_m / 2; + this.halfRoad = halfRoad; + this.leftExtent = halfRoad + group.shoulder_left_m; + this.rightExtent = halfRoad + group.shoulder_right_m; + this.zCenter = params.designElevationM; + this.cutRatio = Math.max(group.cut_slope_ratio, 1e-6); + this.fillRatio = Math.max(group.fill_slope_ratio, 1e-6); + [this.leftRole, this.rightRole] = sideRole(params.sectionMode); + this.ditchSide = params.ditchSide; + this.soilCutRatio = Math.max(params.soilCutRatio ?? group.cut_slope_ratio, 1e-6); + this.twoStage = Boolean( + params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null, + ); + this.groundAt = params.groundAt; + this.rockOffset = params.rockBoundaryOffsetM ?? 0; + this.ditchType = params.ditchType; + // 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). + const slope = params.crossSlopePct / 100; + this.slopePerOffset = params.ditchSide === "left" ? -slope : slope; + + // 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토. + const groundAt = params.groundAt; + if (groundAt !== null) { + this.leftRole = + groundAt(this.leftExtent) > this.roadZ(this.leftExtent) + 1e-3 ? "cut" : "fill"; + this.rightRole = + groundAt(-this.rightExtent) > this.roadZ(-this.rightExtent) + 1e-3 ? "cut" : "fill"; + } + + // 측구 생성 여부(D-1). + if (params.sectionMode === "both_fill") { + this.hasDitch = false; + } else if (params.ditchEnabled !== null && params.ditchEnabled !== undefined) { + this.hasDitch = params.ditchEnabled; + } else if (groundAt !== null) { + const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; + this.hasDitch = groundAt(ditchEdge) > this.roadZ(ditchEdge) + 1e-3; + } else { + this.hasDitch = true; + } + + // 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). + const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; + const outward = params.ditchSide === "left" ? 1 : -1; + const edgeZ = this.roadZ(edgeOffset); + if (this.hasDitch) { + if (params.ditchType === "l_type") { + // L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽. + const width = group.l_ditch_width_m; + const depth = group.l_ditch_depth_m; + this.ditchPoints = [ + [edgeOffset, edgeZ], + [edgeOffset + outward * width, edgeZ - depth], + [edgeOffset + outward * width, edgeZ], + ]; + } else { + // 일반: 상단폭/저폭/깊이 사다리꼴. + const top = group.ditch_top_width_m; + const bottom = Math.min(group.ditch_bottom_width_m, top); + const depth = group.ditch_depth_m; + const inset = (top - bottom) / 2; + this.ditchPoints = [ + [edgeOffset, edgeZ], + [edgeOffset + outward * inset, edgeZ - depth], + [edgeOffset + outward * (inset + bottom), edgeZ - depth], + [edgeOffset + outward * top, edgeZ], + ]; + } + } + } + + /** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */ + roadZ(offsetM: number): number { + return this.zCenter + this.slopePerOffset * offsetM; + } + + /** 짝: `_slope_start`. 사면 시작점 [오프셋 절대값 거리, 표고]. */ + private slopeStart(side: string): [number, number] { + const edgeOffset = side === "left" ? this.leftExtent : this.rightExtent; + const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent); + if (side === this.ditchSide && this.ditchPoints.length) { + const outer = this.ditchPoints[this.ditchPoints.length - 1]; + return [Math.abs(outer[0]), outer[1]]; + } + return [edgeOffset, edgeZ]; + } + + /** 짝: `_rock_boundary_z`. 암반 경계선 표고 = 지반선 + 오프셋(음수=하향). */ + private rockBoundaryZ(side: string, dist: number): number { + const signed = side === "left" ? dist : -dist; + return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset; + } + + /** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */ + knee(side: string): [number, number] | null { + if (!this.twoStage) return null; + const cached = this.rockKnee.get(side); + if (cached !== undefined) return cached; + const [startDist, startZ] = this.slopeStart(side); + let diffPrev = startZ - this.rockBoundaryZ(side, startDist); + let result: [number, number] | null; + if (diffPrev >= 0) { + result = [startDist, startZ]; // 시작부터 토사(경계 위) + } else { + result = null; + let distPrev = startDist; + let dist = startDist + MARCH_STEP_M; + while (dist <= startDist + KNEE_MAX_M) { + const zRock = startZ + (dist - startDist) / this.cutRatio; + const diff = zRock - this.rockBoundaryZ(side, dist); + if (diff >= 0) { + const span = diff - diffPrev; + const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0; + const kneeDist = distPrev + (dist - distPrev) * ratio; + const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio; + result = [kneeDist, kneeZ]; + break; + } + distPrev = dist; + diffPrev = diff; + dist += MARCH_STEP_M; + } + } + this.rockKnee.set(side, result); + return result; + } + + /** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */ + private cutSlopeZ(side: string, dist: number): number { + const [startDist, startZ] = this.slopeStart(side); + const knee = this.twoStage ? this.knee(side) : null; + if (knee !== null) { + const [kneeDist, kneeZ] = knee; + if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio; + return kneeZ + (dist - kneeDist) / this.soilCutRatio; + } + return startZ + (dist - startDist) / this.cutRatio; + } + + /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ + cutCrossDist(side: string): number | null { + const cached = this.cutCross.get(side); + if (cached !== undefined) return cached; + let result: number | null = null; + if (this.groundAt !== null) { + const [startDist] = this.slopeStart(side); + let dist = startDist; + const maxDist = startDist + CROSS_MAX_M; + while (dist <= maxDist) { + const signed = side === "left" ? dist : -dist; + if (this.cutSlopeZ(side, dist) - this.groundAt(signed) >= 0) { + result = dist; + break; + } + dist += MARCH_STEP_M; + } + } + this.cutCross.set(side, result); + return result; + } + + /** 짝: `fill_cross_dist`. 성토 사면이 지반선과 처음 만나는 거리. */ + fillCrossDist(side: string): number | null { + const cached = this.fillCross.get(side); + if (cached !== undefined) return cached; + let result: number | null = null; + if (this.groundAt !== null) { + const [startDist, startZ] = this.slopeStart(side); + let dist = startDist; + const maxDist = startDist + CROSS_MAX_M; + while (dist <= maxDist) { + const signed = side === "left" ? dist : -dist; + const fillLine = startZ - (dist - startDist) / this.fillRatio; + if (fillLine - this.groundAt(signed) <= 0) { + result = dist; + break; + } + dist += MARCH_STEP_M; + } + } + this.fillCross.set(side, result); + return result; + } + + /** 짝: `fill_ground_slope`. 성토측 자연 지반 평균 경사(rise/run). */ + fillGroundSlope(): number | null { + if (this.groundAt === null) return null; + const slopes: number[] = []; + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + if (role !== "fill") continue; + const [startDist] = this.slopeStart(side); + const endDist = this.fillCrossDist(side) ?? startDist + 10; + const run = endDist - startDist; + if (run <= 1e-6) continue; + const sign = side === "left" ? 1 : -1; + const rise = Math.abs(this.groundAt(sign * endDist) - this.groundAt(sign * startDist)); + slopes.push(rise / run); + } + return slopes.length ? Math.min(...slopes) : null; + } + + /** 짝: `design_z`. offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종). */ + designZ(offsetM: number, groundM: number): number { + const side = offsetM >= 0 ? "left" : "right"; + const extent = side === "left" ? this.leftExtent : this.rightExtent; + if (Math.abs(offsetM) <= extent + 1e-9) return this.roadZ(offsetM); + // 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). + if (side === this.ditchSide && this.ditchPoints.length) { + const inner = Math.abs(this.ditchPoints[0][0]); + const outer = Math.abs(this.ditchPoints[this.ditchPoints.length - 1][0]); + if (Math.abs(offsetM) >= inner - 1e-9 && Math.abs(offsetM) <= outer + 1e-9) { + const points = this.ditchPoints; + for (let index = 1; index < points.length; index += 1) { + const x0 = Math.abs(points[index - 1][0]); + const z0 = points[index - 1][1]; + const x1 = Math.abs(points[index][0]); + const z1 = points[index][1]; + if (Math.abs(offsetM) > x1 + 1e-9) continue; + const span = x1 - x0; + if (span <= 1e-9) return z1; + return z0 + (z1 - z0) * ((Math.abs(offsetM) - x0) / span); + } + return points[points.length - 1][1]; + } + } + const role = side === "left" ? this.leftRole : this.rightRole; + const [startDist, startZ] = this.slopeStart(side); + const dist = Math.abs(offsetM); + const run = dist - startDist; + if (role === "cut") { + const cross = this.cutCrossDist(side); + if (cross !== null && dist >= cross) return groundM; + return Math.min(this.cutSlopeZ(side, dist), groundM); + } + const cross = this.fillCrossDist(side); + if (cross !== null && dist >= cross) return groundM; + return Math.max(startZ - run / this.fillRatio, groundM); + } + + /** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */ + breakpoints(): number[] { + const points = [0, this.leftExtent, -this.rightExtent]; + for (const [offset] of this.ditchPoints) points.push(offset); + if (this.twoStage) { + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + const knee = role === "cut" ? this.knee(side) : null; + if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]); + } + } + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side); + if (cross !== null) points.push(side === "left" ? cross : -cross); + } + return points; + } +} + +/** + * 짝: `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; + const geometry = new SectionGeometry({ + designElevationM: centerElevation, + group, + sectionMode, + ditchSide: resolvedDitchSide, + ditchType, + crossSlopePct, + groundAt, + soilCutRatio: resolveGroup("soil", options.standard).cut_slope_ratio, + rockBoundaryOffsetM, + twoStageSlope: enableTwoStage, + ditchEnabled: options.ditchEnabled ?? null, + }); + + // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). + const minOffset = valid[0][0]; + const maxOffset = valid[valid.length - 1][0]; + const mergedSet = new Set(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; + 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), + two_stage_slope: geometry.twoStage, + fill_slope_ratio: round4(geometry.fillRatio), + roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent), + carriageway_width_m: round4(group.road_width_m), + 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.halfRoad), + elevation_m: round4(geometry.roadZ(geometry.halfRoad)), + }, + right: { + offset_m: round4(-geometry.halfRoad), + elevation_m: round4(geometry.roadZ(-geometry.halfRoad)), + }, + }, + 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, + }; + 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; +} diff --git a/common_util/common_util_cross_design_areas.ts b/common_util/common_util_cross_design_areas.ts new file mode 100644 index 00000000..081cea98 --- /dev/null +++ b/common_util/common_util_cross_design_areas.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * common_util_cross_design_areas.ts + * 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리. + * + * ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠ + * 짝: `B06_Section/B06_Section_Engine_Areas.py` + * 같은 입력에 같은 값을 내야 한다. 회귀 테스트가 두 구현을 실제로 비교한다: + * `tmp/tests/test_b06_cross_design_mirror.py` — 고칠 때 반드시 같이 돌릴 것. + * 왜 두 벌인가: 사용자 조작 중 계산은 브라우저 안에서 끝나야 하고(2026-09-03 사용자 + * 확정), 저장·확정·도면 산출은 서버가 정본으로 다시 계산하기 때문이다. + * + * 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로 설계 로직을 전혀 모른다. + * ========================================================================== */ + +/** + * 오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 [절토, 성토] 면적을 낸다. + * + * diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서 + * 나눠 절·성토가 섞이지 않게 한다. + */ +export function trapezoidAreas(offsets: number[], diffs: number[]): [number, number] { + let cutArea = 0; + let fillArea = 0; + for (let index = 1; index < offsets.length; index += 1) { + const x0 = offsets[index - 1]; + const x1 = offsets[index]; + const d0 = diffs[index - 1]; + const d1 = diffs[index]; + const width = x1 - x0; + if (width <= 0) continue; + if (d0 === 0 && d1 === 0) continue; + if (d0 * d1 < 0) { + // 부호 변화: 영교점에서 두 삼각형으로 분리 + const zeroRatio = d0 / (d0 - d1); + const xZero = x0 + width * zeroRatio; + const leftArea = 0.5 * (xZero - x0) * Math.abs(d0); + const rightArea = 0.5 * (x1 - xZero) * Math.abs(d1); + if (d0 > 0) { + cutArea += leftArea; + fillArea += rightArea; + } else { + fillArea += leftArea; + cutArea += rightArea; + } + continue; + } + const area = 0.5 * (d0 + d1) * width; + if (area >= 0) cutArea += area; + else fillArea += -area; + } + return [cutArea, fillArea]; +} + +/** + * 절토 면적을 암반 경계선 기준으로 [토사, 암반]으로 나눈다. + * + * 암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께 + * `t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에 + * 대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상 + * `max(d, 0)`이라 `trapezoidAreas`의 절토 면적과 정확히 일치한다. + * + * 두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야 + * 사다리꼴 적분이 근사가 아닌 정확값이 된다. + */ +export function splitCutAreas( + offsets: number[], + diffs: number[], + soilDepthM: number, +): [number, number] { + const t0 = Math.max(soilDepthM, 0); + let soilArea = 0; + let rockArea = 0; + for (let index = 1; index < offsets.length; index += 1) { + const x0 = offsets[index - 1]; + const x1 = offsets[index]; + const d0 = diffs[index - 1]; + const d1 = diffs[index]; + const width = x1 - x0; + if (width <= 0) continue; + const ratios = [0, 1]; + for (const level of [0, t0]) { + if ((d0 - level) * (d1 - level) < 0) ratios.push((level - d0) / (d1 - d0)); + } + ratios.sort((a, b) => a - b); + for (let step = 1; step < ratios.length; step += 1) { + const ratioA = ratios[step - 1]; + const ratioB = ratios[step]; + const span = width * (ratioB - ratioA); + if (span <= 0) continue; + const dA = d0 + (d1 - d0) * ratioA; + const dB = d0 + (d1 - d0) * ratioB; + soilArea += ((Math.min(Math.max(dA, 0), t0) + Math.min(Math.max(dB, 0), t0)) / 2) * span; + rockArea += ((Math.max(dA - t0, 0) + Math.max(dB - t0, 0)) / 2) * span; + } + } + return [soilArea, rockArea]; +}