From aac091dacc81dc2aa75bd7847df05399bdb8baca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:10:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EB=B0=9C=EC=9E=90=EA=B5=AD):=20346-?= =?UTF-8?q?2=20=ED=8F=89=EB=A9=B4=20=EB=B0=9C=EC=9E=90=EA=B5=AD=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0(=EC=88=9C=EC=88=98)=20=E2=80=94=20B07=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=20=EB=B9=84=ED=83=88=EC=9D=98=20=E2=91=A0=20?= =?UTF-8?q?=EC=A7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile/logic/B05_Profile_PlanFootprint.ts — 측점(중심 · 진행 방향) + 횡단 설계 결과 → 도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금 - 짝 = B07_DesignDetail_Engine_Cad_PlanSlope.py(route_frame · side_is_cut · slope_end · slope_pieces) · 머리에 짝 표시 - 거울 시험 test_346_2_plan_footprint_mirror.py · 그리기는 2파(sub2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../logic/B05_Profile_PlanFootprint.ts | 289 ++++++++++++++++++ .../B07_DesignDetail_Engine_Cad_PlanSlope.py | 4 + .../test_346_2_plan_footprint_mirror.py | 184 +++++++++++ 3 files changed, 477 insertions(+) create mode 100644 B05_Profile/logic/B05_Profile_PlanFootprint.ts create mode 100644 resources/tester/test_346_2_plan_footprint_mirror.py diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts new file mode 100644 index 000000000..ffa4d7d95 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -0,0 +1,289 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_PlanFootprint.ts + * 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2) + * + * ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py` + * (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면 + * 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다. + * `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄. + * 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` + * + * 순수 계산 — 화면 · three · fetch · sessionStorage 없음 · 좌표 = 사업지 m · 오프셋 좌(+) · 우(-). + * ========================================================================== */ + +/** 횡단 설계선 · 지반 표본 한 점(`CrossDesignEdge` · `CrossGroundSample` 꼴). */ +export interface FootprintPoint { + offset_m?: number | null; + elevation_m?: number | null; + valid?: boolean; +} + +/** 측점 한 자리 — 중심 · 진행 방향 · 그 측점 횡단 설계 결과 · 지반 표본. */ +export interface FootprintStation { + chainage_m: number; + x: number; + y: number; + /** 진행 방향(크기 무관) — 좌측 법선 = (−ty, tx). */ + tx: number; + ty: number; + design: { + road_edges?: { + left?: FootprintPoint | null; + right?: FootprintPoint | null; + } | null; + design_line?: FootprintPoint[] | null; + } | null; + samples: FootprintPoint[]; +} + +/** `slope_pieces` 의 측점 줄. */ +export interface FootprintRecord { + chainage_m: number; + left_edge_m: number; + left_end_m: number; + right_edge_m: number; + right_end_m: number; + left_cut?: boolean; + right_cut?: boolean; + x?: number; + y?: number; + nx?: number; + ny?: number; +} + +type Line = number[][]; + +export interface PlanFootprint { + /** 도로 끝 [좌, 우]. */ + edge: Line[]; + /** 비탈 끝 — 쌓기(성토) 구간 · 깎기(절토) 구간. */ + fill: Line[]; + cut: Line[]; + /** 비탈 눈금 — 도로 끝에서 비탈 끝으로(긴 · 짧은 번갈아). */ + ticks: Line[]; +} + +/** 비탈 눈금 간격(1/1,200 실거리 m). 짝: `TICK_STEP_M`. */ +export const TICK_STEP_M = 2.0; + +type Side = "left" | "right"; +const SIDES: Side[] = ["left", "right"]; +const KEYS = ["left_edge_m", "left_end_m", "right_edge_m", "right_end_m"] as const; + +/** 파이썬 `round(v, n)` 대용 — 반올림 경계(…5)만 다를 수 있음(1e-n 안). */ +const roundTo = (value: number, digits: number): number => { + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +}; + +function bisectLeft(values: number[], target: number): number { + let low = 0; + let high = values.length; + while (low < high) { + const middle = (low + high) >> 1; + if (values[middle] < target) low = middle + 1; + else high = middle; + } + return low; +} + +/** 노선 [누가거리, x, y] 위 그 자리와 좌측 법선 [x, y, nx, ny]. 짝: `route_frame`. */ +export function routeFrame(route: Array<[number, number, number]>, chainage: number): number[] { + const chainages = route.map((item) => item[0]); + const index = Math.min(Math.max(bisectLeft(chainages, chainage), 1), route.length - 1); + const [c0, x0, y0] = route[index - 1]; + const [c1, x1, y1] = route[index]; + const dx = x1 - x0; + const dy = y1 - y0; + const length = Math.hypot(dx, dy) || 1.0; + const ratio = c1 > c0 ? (chainage - c0) / (c1 - c0) : 0.0; + return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length]; +} + +function interp(records: Array>, chainage: number, key: string): number { + const chainages = records.map((record) => record.chainage_m); + const index = bisectLeft(chainages, chainage); + if (index <= 0) return records[0][key]; + if (index >= records.length) return records[records.length - 1][key]; + const before = records[index - 1]; + const after = records[index]; + const span = after.chainage_m - before.chainage_m; + const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0; + return before[key] + (after[key] - before[key]) * ratio; +} + +function groundOf(samples: FootprintPoint[]): Array<[number, number]> { + return samples + .filter( + (item) => + ("valid" in item ? Boolean(item.valid) : true) && typeof item.elevation_m === "number", + ) + .map((item): [number, number] => [Number(item.offset_m), Number(item.elevation_m)]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]); +} + +function groundAt(ground: Array<[number, number]>, offset: number): number { + const offsets = ground.map(([x]) => x); + const index = Math.min(Math.max(bisectLeft(offsets, offset), 1), ground.length - 1); + const [x0, z0] = ground[index - 1] ?? ground[ground.length - 1]; + const [x1, z1] = ground[index]; + return z0 + (z1 - z0) * (x1 > x0 ? (offset - x0) / (x1 - x0) : 0.0); +} + +/** 도로 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 깎기. 짝: `side_is_cut`. */ +export function sideIsCut(samples: FootprintPoint[], edge: number, elevation: number): boolean { + const ground = groundOf(samples); + return ground.length >= 2 && groundAt(ground, edge) > elevation; +} + +/** 도로 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 짝: `slope_end`. */ +export function slopeEnd( + designLine: FootprintPoint[], + samples: FootprintPoint[], + edge: number, + side: Side, +): number { + const ground = groundOf(samples); + const points = designLine + .map((p): [number, number] => [Number(p.offset_m), Number(p.elevation_m)]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const outward = + side === "left" + ? points.filter((p) => p[0] > edge) + : [...points].reverse().filter((p) => p[0] < edge); + if (!ground.length) return outward.length ? outward[outward.length - 1][0] : edge; + let end = edge; + let differs = false; + for (const [offset, elevation] of outward) { + if (Math.abs(elevation - groundAt(ground, offset)) <= 1e-3) return differs ? offset : end; + differs = true; + end = offset; + } + return end; +} + +/** 측점 한 자리 → `slope_pieces` 줄 · 도로 끝이 없으면 null. 짝: `slope_records` 한 줄. */ +export function footprintRecord(station: FootprintStation): FootprintRecord | null { + const edges = station.design?.road_edges; + const left = Number(edges?.left?.offset_m); + const right = Number(edges?.right?.offset_m); + if (edges?.left?.offset_m == null || edges?.right?.offset_m == null) return null; + if (!Number.isFinite(left) || !Number.isFinite(right)) return null; + const line = station.design?.design_line ?? []; + const samples = station.samples ?? []; + const record: FootprintRecord = { + chainage_m: station.chainage_m, + left_edge_m: left, + left_end_m: slopeEnd(line, samples, left, "left"), + right_edge_m: right, + right_end_m: slopeEnd(line, samples, right, "right"), + }; + for (const side of SIDES) { + const elevation = edges[side]?.elevation_m; + if (typeof elevation === "number") { + record[`${side}_cut`] = sideIsCut(samples, record[`${side}_edge_m`], elevation); + } + } + const length = Math.hypot(station.tx, station.ty) || 1.0; + record.x = station.x; + record.y = station.y; + record.nx = -station.ty / length; + record.ny = station.tx / length; + return record; +} + +/** 한 선을 참 · 거짓 구간으로 끊음 — 바뀌는 자리 점은 앞뒤가 나눠 가져 이어짐. */ +function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] { + const runs: { true: Line[]; false: Line[] } = { true: [], false: [] }; + let current: Line = []; + let previous: boolean | null = null; + points.forEach((point, index) => { + const flag = flags[index]; + if (flag !== previous) { + if (current.length) current.push(point); + current = [point]; + runs[`${flag}`].push(current); + previous = flag; + } else { + current.push(point); + } + }); + return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)]; +} + +/** 측점 줄 → 도로 끝선 · 쌓기 · 깎기 비탈 끝선 · 눈금. 짝: `slope_pieces`. */ +export function slopePieces( + route: Array<[number, number, number]>, + input: FootprintRecord[], + startM: number, + endM: number, + stepM: number = TICK_STEP_M, +): PlanFootprint { + const records = input + .map((record) => ({ + ...record, + left_cut: Number(Boolean(record.left_cut)), + right_cut: Number(Boolean(record.right_cut)), + })) + .sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array>; + if (route.length < 2 || !records.length) return { edge: [], fill: [], cut: [], ticks: [] }; + const byChainage = new Map>(); + for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record); + const samples = new Set(); + for (const record of records) { + if (startM <= record.chainage_m && record.chainage_m <= endM) + samples.add(roundTo(record.chainage_m, 3)); + } + for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3)); + const lines: Record = Object.fromEntries(KEYS.map((key) => [key, []])); + const cut: Record = { left: [], right: [] }; + const ticks: Line[] = []; + [...samples] + .sort((a, b) => a - b) + .forEach((chainage, index) => { + const record = byChainage.get(chainage); + const [x, y, nx, ny] = + record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined) + ? [record.x, record.y, record.nx, record.ny] + : routeFrame(route, chainage); + const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)])); + const points = Object.fromEntries( + KEYS.map((key) => [ + key, + [roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)], + ]), + ); + for (const key of KEYS) lines[key].push(points[key]); + for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5); + // 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례). + for (const side of SIDES) { + const edge = offsets[`${side}_edge_m`]; + const end = offsets[`${side}_end_m`]; + if (Math.abs(end - edge) < 0.05) continue; + const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0; + ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]); + } + }); + const cutLines: Line[] = []; + const fillLines: Line[] = []; + for (const side of SIDES) { + const [cutRuns, fillRuns] = splitRuns(lines[`${side}_end_m`], cut[side]); + cutLines.push(...cutRuns); + fillLines.push(...fillRuns); + } + return { edge: [lines.left_edge_m, lines.right_edge_m], fill: fillLines, cut: cutLines, ticks }; +} + +/** 측점들 → 평면 발자국(도로 끝이 없는 측점은 뺌). */ +export function planFootprint( + route: Array<[number, number, number]>, + stations: FootprintStation[], + startM: number, + endM: number, + stepM: number = TICK_STEP_M, +): PlanFootprint { + const records = stations + .map(footprintRecord) + .filter((record): record is FootprintRecord => record !== null); + return slopePieces(route, records, startM, endM, stepM); +} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py index 3ef15e745..d5c327e7f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -1,6 +1,10 @@ """B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장). `_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. + +⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · side_is_cut · slope_end · +slope_pieces) — 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. +거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` """ import math diff --git a/resources/tester/test_346_2_plan_footprint_mirror.py b/resources/tester/test_346_2_plan_footprint_mirror.py new file mode 100644 index 000000000..e1a26ef04 --- /dev/null +++ b/resources/tester/test_346_2_plan_footprint_mirror.py @@ -0,0 +1,184 @@ +"""346-2 평면 발자국 — TS(`B05_Profile/logic/B05_Profile_PlanFootprint.ts`) ↔ 파이썬 +(`B07_DesignDetail_Engine_Cad_PlanSlope.py` · `slope_records` 한 줄) 거울 시험. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_346_2_plan_footprint_mirror.py -q +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402 + route_frame, + side_is_cut, + slope_end, + slope_pieces, +) + +TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +TOL = 2e-4 # 점 좌표 round(·, 4) — 반올림 경계만 1e-4 갈릴 수 있음 + +VERTICES = [(0.0, 0.0), (100.0, 0.0), (160.0, 60.0), (170.0, 140.0)] + + +def _route() -> list[tuple[float, float, float]]: + route, chain = [], 0.0 + for index, (x, y) in enumerate(VERTICES): + if index: + chain += math.hypot(x - VERTICES[index - 1][0], y - VERTICES[index - 1][1]) + route.append((chain, x, y)) + return route + + +def _section(chainage: float) -> tuple[dict, list[dict]]: + """측점 횡단 — 지반 기울기가 측점마다 바뀌어 깎기 · 쌓기가 뒤집힘 · 노면 ±2.5 m.""" + tilt = 0.4 * math.sin(chainage / 37.0) + road = 100.0 + 0.6 * math.cos(chainage / 23.0) + offsets = [-20.0 + 0.5 * i for i in range(81)] + + def ground(o: float) -> float: + return 100.0 + tilt * o + 0.3 * math.sin(o / 3.0) + + samples = [ + {"offset_m": o, "elevation_m": ground(o), "valid": abs(o) < 18.5 or chainage < 100} + for o in offsets + ] + line = [] + for o in offsets: + if abs(o) <= 2.5: + line.append({"offset_m": o, "elevation_m": road}) + continue + run = abs(o) - 2.5 + g = ground(o) + edge_g = ground(2.5 if o > 0 else -2.5) + z = min(g, road + run) if edge_g > road else max(g, road - run / 1.5) + line.append({"offset_m": o, "elevation_m": z}) + design = { + "road_edges": { + "left": {"offset_m": 2.5, "elevation_m": road}, + "right": {"offset_m": -2.5, "elevation_m": road}, + }, + "design_line": line, + } + return design, samples + + +def _stations() -> list[dict]: + route = _route() + total = route[-1][0] + stations = [] + chainages = [20.0 * i for i in range(int(total // 20) + 1)] + [total, 60.0] # 겹친 측점 하나 + for chainage in chainages: + x, y, nx, ny = route_frame(route, chainage) + design, samples = _section(chainage) + stations.append( + { + "chainage_m": chainage, + "x": x, + "y": y, + "tx": ny, + "ty": -nx, + "design": design, + "samples": samples, + } + ) + stations.append({**stations[3], "chainage_m": 70.0, "design": {"road_edges": None}}) + stations.append({**stations[4], "chainage_m": 90.0, "samples": []}) # 지반 없음 + return stations + + +def _py_record(station: dict) -> dict | None: + """`B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄(파일 읽기 뺌).""" + design = station["design"] or {} + edges = design.get("road_edges") or {} + try: + left_edge = float(edges["left"]["offset_m"]) + right_edge = float(edges["right"]["offset_m"]) + except (KeyError, TypeError, ValueError): + return None + line, samples = design.get("design_line") or [], station["samples"] or [] + record = { + "chainage_m": float(station["chainage_m"]), + "left_edge_m": left_edge, + "left_end_m": slope_end(line, samples, left_edge, "left"), + "right_edge_m": right_edge, + "right_end_m": slope_end(line, samples, right_edge, "right"), + } + for side in ("left", "right"): + elevation = edges[side].get("elevation_m") + if isinstance(elevation, (int, float)): + record[f"{side}_cut"] = side_is_cut(samples, record[f"{side}_edge_m"], elevation) + length = math.hypot(station["tx"], station["ty"]) or 1.0 + record.update( + x=station["x"], y=station["y"], nx=-station["ty"] / length, ny=station["tx"] / length + ) + return record + + +DRIVER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { planFootprint } from "./B05_Profile_PlanFootprint.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf-8")); +writeFileSync(process.argv[3], JSON.stringify(cases.map((c) => + planFootprint(c.route, c.stations, c.start, c.end, c.step)))); +""" + +CASES = [(0.0, 400.0, 2.0), (35.0, 130.0, 2.0), (0.0, 400.0, 10.0)] + + +@pytest.fixture(scope="module") +def ts_result(tmp_path_factory: pytest.TempPathFactory) -> list[dict]: + out = tmp_path_factory.mktemp("ts_footprint") + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + payload = [ + {"route": _route(), "stations": _stations(), "start": s, "end": e, "step": t} + for s, e, t in CASES + ] + (out / "in.json").write_text(json.dumps(payload), encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def _same_lines(want: list, got: list, name: str) -> None: + assert len(want) == len(got), f"{name} 선 수" + for line_w, line_g in zip(want, got): + assert len(line_w) == len(line_g), f"{name} 점 수" + for pw, pg in zip(line_w, line_g): + assert abs(pw[0] - pg[0]) < TOL and abs(pw[1] - pg[1]) < TOL, f"{name} {pw} {pg}" + + +@pytest.mark.parametrize("index", range(len(CASES))) +def test_발자국이_두_쪽에서_같다(ts_result, index: int) -> None: + start, end, step = CASES[index] + records = [r for r in map(_py_record, _stations()) if r is not None] + edge, fill, cut, ticks = slope_pieces(_route(), records, start, end, step) + got = ts_result[index] + _same_lines(edge["lines"], got["edge"], "도로 끝") + _same_lines(fill["lines"], got["fill"], "쌓기") + _same_lines(cut["lines"], got["cut"], "깎기") + _same_lines(ticks["ticks"], got["ticks"], "눈금") + + +def test_깎기_쌓기가_함께_나옴(ts_result) -> None: + got = ts_result[0] + assert len(got["edge"]) == 2 and got["fill"] and got["cut"] and got["ticks"]