From e1fec62d358174ed35ab9e32cc64b7f12a226e43 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 07:23:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=82=AC=ED=86=A0=EC=9E=A5=20?= =?UTF-8?q?=EC=84=B1=ED=86=A0=20=EB=8B=A8=EB=A9=B4=20=EC=97=94=EC=A7=84(?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=EC=8D=AC=C2=B7TS=20=EC=A7=9D)=20+=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EB=B6=80=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유용토운반작업장(구 사토장) — 노선 옆에 남는 흙을 쌓는 성토 단면. - `common_util_spoil_fill.{ts,py}` 신설(한 벌, 거울 시험) — 평상(노면 끝 높이) → 1:n 비탈 → 지반과 만나는 데까지. 단면적은 적분으로 정확히 셈. 폭 시작점 = **노면 끝**(사용자 확정) ⇒ 그 구간 노견도 이 성토 안에 듦. 폭 상한 = **지반 샘플이 있는 데까지**. 상한에서도 모자라면 그 폭을 냄(임의로 안 넓힘). 용량에서 폭을 되풀이로 역산하는 `solveSpoilWidthM` 함께 냄. - 등록부 `spoil_bank` 를 넓힘 — 새 종류를 만들지 않음(지식DB 상 같은 시설임). 이름 「유용토운반작업장(구 사토장)」 · 구간형 · 횡단도에 그림 · 칸 `side`(자동=성토 쪽) · `fill_slope_ratio`(비면 노선 성토 기울기) · `extra_distance_m`. - 기울기·적치높이 기본값은 지어내지 않음 — 지식DB §4 가 근거 없음을 못 박음. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 32 ++- common_util/common_util_spoil_fill.py | 223 +++++++++++++++++ common_util/common_util_spoil_fill.ts | 241 +++++++++++++++++++ 3 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 common_util/common_util_spoil_fill.py create mode 100644 common_util/common_util_spoil_fill.ts diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index a6b949d4..aa468339 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -1711,14 +1711,40 @@ { "type_id": "spoil_bank", "group": "E", - "name": "사토장", - "placement": "site", + "name": "유용토운반작업장(구 사토장)", + "placement": "interval", "style": { "color": "#8c9a2e", "abbr": "사토장" }, - "drawing_views": ["plan", "quantity"], + "drawing_views": ["plan", "cross_section", "quantity"], "options": [ + { + "key": "side", + "label": "쌓는 쪽", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)", + "required": false, + "phase": "b05" + }, + { + "key": "fill_slope_ratio", + "label": "비탈 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "extra_distance_m", + "label": "추가 운반거리", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "detail" + }, { "key": "area_m2", "label": "면적", diff --git a/common_util/common_util_spoil_fill.py b/common_util/common_util_spoil_fill.py new file mode 100644 index 00000000..72945721 --- /dev/null +++ b/common_util/common_util_spoil_fill.py @@ -0,0 +1,223 @@ +"""유용토운반작업장(구 사토장) — 노선 옆에 남는 흙을 쌓는 **성토 단면**. + +⚠⚠ TS 짝 파일과 **한 벌**이다 — `common_util/common_util_spoil_fill.ts`. + 거울 시험: `tmp/tests/test_spoil_fill_mirror.py` (고칠 때 같이 돌릴 것). + 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽에 같은 수가 있어야 한다. + +── 무엇을 재나 ───────────────────────────────────────────────────── +사용자 확정(2026-09-09): **폭의 시작점은 노면 끝**(노견이 시작하는 자리)이다. +노견 바깥 끝이 아니다 — 그래서 **그 구간의 노견도 이 성토 안에 들어간다**. + + 노면 끝(x0, z0) + ├──── 평상(폭 w, 노면 끝 높이 그대로) ────┐ + │ ╲ 1:n 비탈 + │ ╲ + ────────────── 원지반 ─────────────────────────╳ 비탈 끝(toe) + +단면적 = 이 선과 원지반 사이(설계선이 지반보다 높은 몫). 대략 `w·h + w²/(2n)` 이지만 +지반이 기울어 있으므로 **적분으로 정확히** 센다. + +── 지어내지 않는 것 ──────────────────────────────────────────────── +지식DB `01_임도/02_상세설계/유용토운반작업장.md` §4: + 「부지 면적에서 처리용량을 자동 산정하는 현행 법정 공식, 기본 적치높이, 기본 + 비탈기울기는 확보 근거에 없다. **사용자 협의 없이 기본값을 만들지 않는다**」 +⇒ 기울기 `n` 은 **받아서 쓴다**(비면 그 측점의 노선 성토 기울기를 그대로 씀 — 새 값을 + 만드는 것이 아니라 이미 설계된 값을 따르는 것이다). 높이는 **노면 끝 높이**로 정해진다. +⚠ **폭 상한은 지반 샘플이 있는 데까지**다. 샘플 밖은 지반을 모르므로 넓히지 않는다. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +#: 적분·비탈 추적을 잘게 나누는 폭(m). TS 짝과 같은 값이어야 수가 맞는다. +STEP_M = 0.05 +#: 지반과 만나는 자리를 좁히는 이분법 반복 수. +SOLVE_STEPS = 24 + + +@dataclass(slots=True) +class SpoilFillSection: + """사토장 성토 단면 하나.""" + + area_m2: float = 0.0 + #: 평상 + 비탈을 그린 선 [(offset_m, elevation_m)] — 그리기와 적분이 같은 선을 쓴다. + line: list[tuple[float, float]] = field(default_factory=list) + #: 비탈 끝 오프셋 — 지반과 만난 자리. 못 만나면 샘플 끝에서 잘린다. + toe_offset_m: float = 0.0 + #: 비탈이 지반을 못 만나 잘렸나 — 화면이 「닫히지 않음」을 알린다. + unclosed: bool = False + #: 이 측점에서 넓힐 수 있는 최대 평상 폭(m). + max_width_m: float = 0.0 + + +def _interpolator(ground: list[tuple[float, float]]): + points = sorted( + (float(offset), float(elevation)) + for offset, elevation in ground + if offset is not None and elevation is not None + ) + + def at(offset_m: float) -> float: + if not points: + return float("nan") + if offset_m <= points[0][0]: + return points[0][1] + if offset_m >= points[-1][0]: + return points[-1][1] + for index in range(1, len(points)): + left = points[index - 1] + right = points[index] + if offset_m <= right[0]: + span = right[0] - left[0] + if span <= 0: + return right[1] + ratio = (offset_m - left[0]) / span + return left[1] + (right[1] - left[1]) * ratio + return points[-1][1] + + return at + + +def spoil_max_width_m(ground: list[tuple[float, float]], start_offset_m: float, side: str) -> float: + """그 쪽으로 지반 샘플이 남아 있는 거리(m). 이보다 넓게는 못 쌓는다.""" + offsets = [float(offset) for offset, _ in ground if offset is not None] + if not offsets: + return 0.0 + edge = max(offsets) if side == "left" else min(offsets) + reach = edge - start_offset_m if side == "left" else start_offset_m - edge + return max(reach, 0.0) + + +def spoil_fill_section( + ground: list[tuple[float, float]], + start_offset_m: float, + start_elevation_m: float, + side: str, + width_m: float, + slope_ratio_n: float, +) -> SpoilFillSection: + """사토장 성토 단면 하나. 평상(노면 끝 높이) → 1:n 비탈 → 지반과 만나는 데까지. + + ⚠ 지반보다 낮아지는 몫은 세지 않는다 — 그것은 절토이지 쌓은 흙이 아니다. + """ + sign = 1.0 if side == "left" else -1.0 + ground_at = _interpolator(ground) + max_width = spoil_max_width_m(ground, start_offset_m, side) + empty = SpoilFillSection(toe_offset_m=start_offset_m, max_width_m=round(max_width, 4)) + width = max(float(width_m), 0.0) + ratio = float(slope_ratio_n) + if ratio <= 0 or not ground: + return empty + + platform = min(width, max_width) + + def design_at(u: float) -> float: + return start_elevation_m if u <= platform else start_elevation_m - (u - platform) / ratio + + def offset_at(u: float) -> float: + return start_offset_m + sign * u + + def height_at(u: float) -> float: + return design_at(u) - ground_at(offset_at(u)) + + # ① 비탈이 지반을 만나는 자리 — 평상 끝부터 바깥으로 훑는다. + toe_u = max_width + unclosed = True + if height_at(platform) <= 0: + toe_u = platform + unclosed = False + else: + previous = platform + steps = int((max_width - platform) / STEP_M) + 2 + for step in range(1, steps + 1): + current = min(platform + STEP_M * step, max_width) + if height_at(current) <= 0: + low, high = previous, current + for _ in range(SOLVE_STEPS): + mid = (low + high) / 2 + if height_at(mid) > 0: + low = mid + else: + high = mid + toe_u = high + unclosed = False + break + previous = current + if current >= max_width: + break + + # ② 면적 — 설계선이 지반보다 높은 몫만 사다리꼴로 적분한다. + area = 0.0 + cuts = [0.0] + if 0 < platform < toe_u: + cuts.append(platform) + cuts.append(toe_u) + for index in range(1, len(cuts)): + start, end = cuts[index - 1], cuts[index] + span = end - start + if span <= 0: + continue + count = max(1, math.ceil(span / STEP_M)) + step_width = span / count + for step in range(count): + u_a = start + step_width * step + u_b = u_a + step_width + h_a = max(height_at(u_a), 0.0) + h_b = max(height_at(u_b), 0.0) + area += (h_a + h_b) / 2 * step_width + + # ③ 그리는 선 — 적분과 같은 선을 쓴다(그림과 수량이 어긋나지 않게). + line: list[tuple[float, float]] = [(start_offset_m, start_elevation_m)] + if platform > 0: + line.append((offset_at(platform), start_elevation_m)) + if toe_u > platform: + line.append((offset_at(toe_u), design_at(toe_u))) + if sign < 0: + line.reverse() + + return SpoilFillSection( + area_m2=round(area, 4), + line=[(round(offset, 4), round(elevation, 4)) for offset, elevation in line], + toe_offset_m=round(offset_at(toe_u), 4), + unclosed=unclosed, + max_width_m=round(max_width, 4), + ) + + +def solve_spoil_width_m( + ground: list[tuple[float, float]], + start_offset_m: float, + start_elevation_m: float, + side: str, + slope_ratio_n: float, + target_area_m2: float, +) -> tuple[float, SpoilFillSection]: + """원하는 단면적이 나오도록 **평상 폭을 되풀이로 찾는다**. + + ⚠ 면적은 폭에 대해 단조증가라 이분법으로 충분하다. + ⚠ 상한(지반 샘플이 있는 데까지)에서도 모자라면 **그 폭을 돌려준다** — 못 담는 몫은 + 부르는 쪽이 「남은 사토」로 드러낸다(임의로 넓히지 않는다). + """ + max_width = spoil_max_width_m(ground, start_offset_m, side) + + def at(width_m: float) -> SpoilFillSection: + return spoil_fill_section( + ground, start_offset_m, start_elevation_m, side, width_m, slope_ratio_n + ) + + if not target_area_m2 > 0: + return 0.0, at(0.0) + full = at(max_width) + if full.area_m2 <= target_area_m2: + return round(max_width, 4), full + low, high = 0.0, max_width + for _ in range(SOLVE_STEPS): + mid = (low + high) / 2 + if at(mid).area_m2 < target_area_m2: + low = mid + else: + high = mid + width_m = round(high, 4) + return width_m, at(width_m) diff --git a/common_util/common_util_spoil_fill.ts b/common_util/common_util_spoil_fill.ts new file mode 100644 index 00000000..795e1a81 --- /dev/null +++ b/common_util/common_util_spoil_fill.ts @@ -0,0 +1,241 @@ +/* ============================================================================= + * common_util_spoil_fill.ts + * 유용토운반작업장(구 사토장) — 노선 옆에 남는 흙을 쌓는 **성토 단면**. + * + * ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — `common_util/common_util_spoil_fill.py`. + * 거울 시험: `tmp/tests/test_spoil_fill_mirror.py`. 어느 쪽을 고치든 같이 돌릴 것. + * 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽 수가 같아야 한다. + * + * ── 무엇을 재나 ───────────────────────────────────────────────────── + * 사용자 확정(2026-09-09): **폭의 시작점은 노면 끝**(노견이 시작하는 자리)이다. + * 노견 바깥 끝이 아니다 — 그래서 **그 구간의 노견도 이 성토 안에 들어간다**. + * + * 노면 끝(x0, z0) + * ├──── 평상(폭 w, 노면 끝 높이 그대로) ────┐ + * │ ╲ 1:n 비탈 + * │ ╲ + * ────────────── 원지반 ─────────────────────────╳ 비탈 끝(toe) + * + * 단면적 = 이 선과 원지반 사이(설계선이 지반보다 높은 몫). 대략 `w·h + w²/(2n)` 이지만 + * 지반이 기울어 있으므로 **적분으로 정확히** 센다. + * + * ── 지어내지 않는 것 ──────────────────────────────────────────────── + * 지식DB `01_임도/02_상세설계/유용토운반작업장.md` §4: + * 「부지 면적에서 처리용량을 자동 산정하는 현행 법정 공식, 기본 적치높이, 기본 + * 비탈기울기는 확보 근거에 없다. **사용자 협의 없이 기본값을 만들지 않는다**」 + * ⇒ 기울기 `n` 은 **받아서 쓴다**(비면 그 측점의 노선 성토 기울기를 그대로 씀 — 새 값을 + * 만드는 것이 아니라 이미 설계된 값을 따르는 것이다). 높이는 **노면 끝 높이**로 정해진다. + * ⚠ **폭 상한은 지반 샘플이 있는 데까지**다. 샘플 밖은 지반을 모르므로 넓히지 않는다. + * ========================================================================== */ + +/** 지반 표본 한 점. `offset_m` 오름차순으로 주어야 한다. */ +export interface SpoilGroundSample { + offset_m: number; + elevation_m: number; +} + +export interface SpoilFillInput { + /** 원지반 — 오름차순. */ + ground: SpoilGroundSample[]; + /** 노면 끝(= 노견이 시작하는 자리)의 오프셋·표고. */ + startOffsetM: number; + startElevationM: number; + /** 쌓는 쪽. 좌 = +offset, 우 = −offset (config 5-4-2 의 부호 규약). */ + side: "left" | "right"; + /** 평상 폭(m). 0 이면 비탈만 남는다. */ + widthM: number; + /** 비탈 기울기 1:n 의 n. 0 이하면 계산하지 않는다. */ + slopeRatioN: number; +} + +export interface SpoilFillSection { + /** 이 측점의 사토장 성토 단면적(㎡). */ + area_m2: number; + /** 평상 + 비탈을 그린 선(오프셋 오름차순). 그리기와 적분이 같은 선을 쓴다. */ + line: SpoilGroundSample[]; + /** 비탈 끝 오프셋 — 지반과 만난 자리. 못 만나면 샘플 끝에서 잘린다. */ + toeOffsetM: number; + /** 비탈이 지반을 못 만나 샘플 끝에서 잘렸나 — 화면이 「닫히지 않음」을 알린다. */ + unclosed: boolean; + /** 이 측점에서 넓힐 수 있는 최대 평상 폭(m) — 지반 샘플이 있는 데까지. */ + maxWidthM: number; +} + +/** 짝: 파이썬 `round(value, 4)`. */ +function round4(value: number): number { + return Math.round(value * 1e4) / 1e4; +} + +/** 적분·비탈 추적 잘게 나누는 폭(m). 0.05 m 면 20 m 폭에서 400 칸이다. */ +const STEP_M = 0.05; +/** 지반과 만나는 자리를 좁히는 이분법 반복 수. */ +const SOLVE_STEPS = 24; + +function interpolator(ground: SpoilGroundSample[]): (offsetM: number) => number { + const points = ground + .filter((item) => Number.isFinite(item.offset_m) && Number.isFinite(item.elevation_m)) + .sort((a, b) => a.offset_m - b.offset_m); + return (offsetM: number): number => { + if (!points.length) return Number.NaN; + if (offsetM <= points[0].offset_m) return points[0].elevation_m; + const last = points[points.length - 1]; + if (offsetM >= last.offset_m) return last.elevation_m; + for (let index = 1; index < points.length; index += 1) { + const a = points[index - 1]; + const b = points[index]; + if (offsetM <= b.offset_m) { + const span = b.offset_m - a.offset_m; + if (span <= 0) return b.elevation_m; + const ratio = (offsetM - a.offset_m) / span; + return a.elevation_m + (b.elevation_m - a.elevation_m) * ratio; + } + } + return last.elevation_m; + }; +} + +/** 그 쪽으로 지반 샘플이 남아 있는 거리(m). 이보다 넓게는 못 쌓는다. */ +export function spoilMaxWidthM(input: { + ground: SpoilGroundSample[]; + startOffsetM: number; + side: "left" | "right"; +}): number { + const offsets = input.ground + .filter((item) => Number.isFinite(item.offset_m)) + .map((item) => item.offset_m); + if (!offsets.length) return 0; + const edge = input.side === "left" ? Math.max(...offsets) : Math.min(...offsets); + const reach = input.side === "left" ? edge - input.startOffsetM : input.startOffsetM - edge; + return Math.max(reach, 0); +} + +/** + * 사토장 성토 단면 하나. 평상(노면 끝 높이) → 1:n 비탈 → 지반과 만나는 데까지. + * + * ⚠ 지반보다 낮아지는 몫은 세지 않는다 — 그것은 절토이지 쌓은 흙이 아니다. + */ +export function spoilFillSection(input: SpoilFillInput): SpoilFillSection { + const sign = input.side === "left" ? 1 : -1; + const groundAt = interpolator(input.ground); + const maxWidth = spoilMaxWidthM(input); + const empty: SpoilFillSection = { + area_m2: 0, + line: [], + toeOffsetM: input.startOffsetM, + unclosed: false, + maxWidthM: maxWidth, + }; + const width = Math.max(input.widthM, 0); + const ratio = input.slopeRatioN; + if (!Number.isFinite(width) || !Number.isFinite(ratio) || ratio <= 0) return empty; + if (!input.ground.length || !Number.isFinite(input.startElevationM)) return empty; + + const platform = Math.min(width, maxWidth); + /** 바깥으로 `u` m 나간 자리의 설계고. 평상 끝부터 1:n 으로 내려간다. */ + const designAt = (u: number): number => + u <= platform ? input.startElevationM : input.startElevationM - (u - platform) / ratio; + const offsetAt = (u: number): number => input.startOffsetM + sign * u; + const heightAt = (u: number): number => designAt(u) - groundAt(offsetAt(u)); + + // ① 비탈이 지반을 만나는 자리 — 평상 끝부터 바깥으로 훑는다. + let toeU = maxWidth; + let unclosed = true; + if (heightAt(platform) <= 0) { + // 평상 끝이 이미 지반 아래·같음 ⇒ 쌓을 것이 없다. + toeU = platform; + unclosed = false; + } else { + let previous = platform; + // ⚠ 걸음은 **정수 번호**로 센다 — 실수를 더해 나가면 파이썬 짝과 자리가 미세하게 갈린다. + const steps = Math.floor((maxWidth - platform) / STEP_M) + 2; + for (let step = 1; step <= steps; step += 1) { + const current = Math.min(platform + STEP_M * step, maxWidth); + if (heightAt(current) <= 0) { + let low = previous; + let high = current; + for (let step = 0; step < SOLVE_STEPS; step += 1) { + const mid = (low + high) / 2; + if (heightAt(mid) > 0) low = mid; + else high = mid; + } + toeU = high; + unclosed = false; + break; + } + previous = current; + if (current >= maxWidth) break; + } + } + + // ② 면적 — 설계선이 지반보다 높은 몫만 사다리꼴로 적분한다. + let area = 0; + const cuts: number[] = [0]; + if (platform > 0 && platform < toeU) cuts.push(platform); + cuts.push(toeU); + for (let index = 1; index < cuts.length; index += 1) { + const from = cuts[index - 1]; + const to = cuts[index]; + const span = to - from; + if (span <= 0) continue; + const steps = Math.max(1, Math.ceil(span / STEP_M)); + const width0 = span / steps; + for (let step = 0; step < steps; step += 1) { + const uA = from + width0 * step; + const uB = uA + width0; + const hA = Math.max(heightAt(uA), 0); + const hB = Math.max(heightAt(uB), 0); + area += ((hA + hB) / 2) * width0; + } + } + + // ③ 그리는 선 — 적분과 같은 선을 쓴다(그림과 수량이 어긋나지 않게). + const line: SpoilGroundSample[] = [ + { offset_m: input.startOffsetM, elevation_m: input.startElevationM }, + ]; + if (platform > 0) { + line.push({ offset_m: offsetAt(platform), elevation_m: input.startElevationM }); + } + if (toeU > platform) { + line.push({ offset_m: offsetAt(toeU), elevation_m: designAt(toeU) }); + } + if (sign < 0) line.reverse(); + + return { + area_m2: round4(area), + // 좌표도 **소수 넷째 자리에서 맞춘다** — 파이썬 짝과 같은 수를 내야 그림과 수량이 붙는다. + line: line.map((point) => ({ + offset_m: round4(point.offset_m), + elevation_m: round4(point.elevation_m), + })), + toeOffsetM: round4(offsetAt(toeU)), + unclosed, + maxWidthM: round4(maxWidth), + }; +} + +/** + * 원하는 단면적이 나오도록 **평상 폭을 되풀이로 찾는다**. + * + * ⚠ 면적은 폭에 대해 단조증가라(넓히면 넓어진 만큼 더 쌓임) 이분법으로 충분하다. + * ⚠ 지반 샘플이 있는 데까지가 상한이라, 상한에서도 모자라면 **그 폭을 돌려준다** — + * 못 담는 몫은 부르는 쪽이 「남은 사토」로 드러낸다(임의로 넓히지 않는다). + */ +export function solveSpoilWidthM( + input: Omit, + targetAreaM2: number, +): { widthM: number; section: SpoilFillSection } { + const maxWidth = spoilMaxWidthM(input); + const at = (widthM: number): SpoilFillSection => spoilFillSection({ ...input, widthM }); + if (!(targetAreaM2 > 0)) return { widthM: 0, section: at(0) }; + const full = at(maxWidth); + if (full.area_m2 <= targetAreaM2) return { widthM: maxWidth, section: full }; + let low = 0; + let high = maxWidth; + for (let step = 0; step < SOLVE_STEPS; step += 1) { + const mid = (low + high) / 2; + if (at(mid).area_m2 < targetAreaM2) low = mid; + else high = mid; + } + const widthM = round4(high); + return { widthM, section: at(widthM) }; +}