From 50620a47b844ace6f661e37b528b68ed37fabcef Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:30:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(B05=20=EB=B0=9C=EC=9E=90=EA=B5=AD):=20?= =?UTF-8?q?346-2=20footprint=20=EB=8B=A8=EA=B3=84=20=E2=80=94=20cross=20?= =?UTF-8?q?=C2=B7=20ground=20=E2=86=92=20=EA=B3=84=EC=95=BD=20PlanFootprin?= =?UTF-8?q?t=20=EA=BC=B4=20=C2=B7=20=EB=B2=94=EC=9C=84=20=EB=B0=96=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile/logic/B05_Profile_Footprint_Stage.ts — footprintStage(ctx) · 측점 지반(우 = 음수 오프셋 · 빈칸 무효) + cross.byKey[key].detail(road_edges · design_line) → 측점 줄 · 바뀐 범위 밖은 ctx.prev.records 같은 열쇠 · 같은 누가거리 줄 그대로 - PlanFootprint.ts — footprintLines(계약 roadLeft · roadRight · slopeLeft · slopeRight + 점마다 깎기 여부 · 눈금 · 측점 줄) · 옛 꼴 이름 SlopePieces · 몸통 sampleLines 하나 - 시험 test_346_2_footprint_stage.py · 거울 시험 tsc 에 --noCheck --noResolve Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../logic/B05_Profile_Footprint_Stage.ts | 100 ++++++++++ .../logic/B05_Profile_PlanFootprint.ts | 75 +++++++- .../tester/test_346_2_footprint_stage.py | 181 ++++++++++++++++++ .../test_346_2_plan_footprint_mirror.py | 3 +- 4 files changed, 350 insertions(+), 9 deletions(-) create mode 100644 B05_Profile/logic/B05_Profile_Footprint_Stage.ts create mode 100644 resources/tester/test_346_2_footprint_stage.py diff --git a/B05_Profile/logic/B05_Profile_Footprint_Stage.ts b/B05_Profile/logic/B05_Profile_Footprint_Stage.ts new file mode 100644 index 000000000..ede536f16 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Footprint_Stage.ts @@ -0,0 +1,100 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_Footprint_Stage.ts + * 계산 묶음 footprint 단계(346-2) — cross · ground → 평면 발자국(계약 `PlanFootprint` 꼴). + * + * 측점마다 = 지반 표본(ground) + 그 측점 횡단 결과(cross.byKey[key].detail = `computeCrossDesign` + * 결과 꼴 · road_edges · design_line) → `footprintRecord`(B07 `slope_records` 한 줄 짝). + * 바뀐 범위 밖 측점은 앞 결과(ctx.prev.records)의 같은 열쇠 · 같은 누가거리 줄을 그대로 씀. + * 선은 노선(route 단계 · 원호 섞임)을 따라 측점 · 2 m 마다 다시 이음(가벼움 · 전 구간). + * + * 순수 계산 — 화면 · three · fetch · sessionStorage 없음. + * ========================================================================== */ + +import type { + ChangeRange, + CrossStationResult, + PipelineStation, + StageContext, +} from "../../A00_Common/design/design_pipeline_types"; +import { + footprintLines, + footprintRecord, + type FootprintLines, + type FootprintPoint, + type FootprintRecord, + type FootprintStation, +} from "./B05_Profile_PlanFootprint"; + +/** 같은 측점으로 볼 누가거리 차(m) — 측점 열쇠 규칙 0.01 m 의 절반. */ +const SAME_CHAINAGE_M = 0.005; + +const sample = (offset: number, z: number | null): FootprintPoint => ({ + offset_m: offset, + elevation_m: z, + valid: z !== null, +}); + +/** 측점 + 그 측점 횡단 결과 → 발자국 입력 한 자리(지반 = 우 바깥 → 중심 → 좌 바깥). */ +export function footprintStation( + station: PipelineStation, + cross: CrossStationResult | undefined, +): FootprintStation { + const ground = station.ground; + const samples: FootprintPoint[] = []; + if (ground) { + for (const item of [...ground.right].reverse()) samples.push(sample(-item.offsetM, item.z)); + samples.push(sample(0, ground.centerZ)); + for (const item of ground.left) samples.push(sample(item.offsetM, item.z)); + } + const [lx, ly] = station.left; + return { + chainage_m: station.chainageM, + x: station.x, + y: station.y, + // 좌측 = (−ty, tx) 이므로 진행 방향 = (ly, −lx) + tx: ly, + ty: -lx, + design: (cross?.detail ?? null) as FootprintStation["design"], + samples, + }; +} + +function changed(range: ChangeRange | null, station: PipelineStation): boolean { + if (!range) return true; + if (range.keys.includes(station.key)) return true; + return ( + station.chainageM >= range.fromM - SAME_CHAINAGE_M && + station.chainageM <= range.toM + SAME_CHAINAGE_M + ); +} + +/** footprint 단계 — `createDesignPipeline({ stages: { footprint: footprintStage } })`. */ +export function footprintStage(ctx: StageContext<"footprint">): FootprintLines { + const stations = ctx.out.ground?.stations ?? []; + const byKey = ctx.out.cross?.byKey ?? {}; + const before = new Map(); + for (const record of (ctx.prev as FootprintLines | null)?.records ?? []) { + if (record.key) before.set(record.key, record); + } + const records: FootprintRecord[] = []; + for (const station of stations) { + const old = before.get(station.key); + if ( + old && + !changed(ctx.range, station) && + Math.abs(old.chainage_m - station.chainageM) < SAME_CHAINAGE_M + ) { + records.push(old); + continue; + } + const record = footprintRecord(footprintStation(station, byKey[station.key])); + if (record) records.push({ ...record, key: station.key }); + } + const route = ctx.out.route; + const line: Array<[number, number, number]> = + route && route.line.length >= 2 + ? route.line.map(([x, y], index) => [route.chainage[index], x, y]) + : stations.map((station) => [station.chainageM, station.x, station.y]); + const endM = route?.lengthM ?? line[line.length - 1]?.[0] ?? 0; + return footprintLines(line, records, 0, endM); +} diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts index ffa4d7d95..b3ea9acfc 100644 --- a/B05_Profile/logic/B05_Profile_PlanFootprint.ts +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -11,6 +11,8 @@ * 순수 계산 — 화면 · three · fetch · sessionStorage 없음 · 좌표 = 사업지 m · 오프셋 좌(+) · 우(-). * ========================================================================== */ +import type { PlanFootprint, Vertex } from "../../A00_Common/design/design_pipeline_types"; + /** 횡단 설계선 · 지반 표본 한 점(`CrossDesignEdge` · `CrossGroundSample` 꼴). */ export interface FootprintPoint { offset_m?: number | null; @@ -38,6 +40,8 @@ export interface FootprintStation { /** `slope_pieces` 의 측점 줄. */ export interface FootprintRecord { + /** 측점 열쇠(footprint 단계가 붙임 · 범위 밖 재사용) — 셈에는 안 씀. */ + key?: string; chainage_m: number; left_edge_m: number; left_end_m: number; @@ -53,7 +57,8 @@ export interface FootprintRecord { type Line = number[][]; -export interface PlanFootprint { +/** B07 `slope_pieces` 꼴(거울 시험 · 도면). */ +export interface SlopePieces { /** 도로 끝 [좌, 우]. */ edge: Line[]; /** 비탈 끝 — 쌓기(성토) 구간 · 깎기(절토) 구간. */ @@ -211,14 +216,20 @@ function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] { return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)]; } -/** 측점 줄 → 도로 끝선 · 쌓기 · 깎기 비탈 끝선 · 눈금. 짝: `slope_pieces`. */ -export function slopePieces( +interface Sampled { + lines: Record<(typeof KEYS)[number], Line>; + cut: Record; + ticks: Line[]; +} + +/** 측점 줄 → 측점 · 눈금 간격마다 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */ +function sampleLines( route: Array<[number, number, number]>, input: FootprintRecord[], startM: number, endM: number, - stepM: number = TICK_STEP_M, -): PlanFootprint { + stepM: number, +): Sampled | null { const records = input .map((record) => ({ ...record, @@ -226,7 +237,7 @@ export function slopePieces( 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: [] }; + if (route.length < 2 || !records.length) return null; const byChainage = new Map>(); for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record); const samples = new Set(); @@ -235,7 +246,7 @@ export function slopePieces( 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 lines = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"]; const cut: Record = { left: [], right: [] }; const ticks: Line[] = []; [...samples] @@ -264,6 +275,20 @@ export function slopePieces( ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]); } }); + return { lines, cut, ticks }; +} + +/** 측점 줄 → 도로 끝선 · 쌓기 · 깎기 비탈 끝선 · 눈금. 짝: `slope_pieces`. */ +export function slopePieces( + route: Array<[number, number, number]>, + input: FootprintRecord[], + startM: number, + endM: number, + stepM: number = TICK_STEP_M, +): SlopePieces { + const sampled = sampleLines(route, input, startM, endM, stepM); + if (!sampled) return { edge: [], fill: [], cut: [], ticks: [] }; + const { lines, cut, ticks } = sampled; const cutLines: Line[] = []; const fillLines: Line[] = []; for (const side of SIDES) { @@ -281,9 +306,43 @@ export function planFootprint( startM: number, endM: number, stepM: number = TICK_STEP_M, -): PlanFootprint { +): SlopePieces { const records = stations .map(footprintRecord) .filter((record): record is FootprintRecord => record !== null); return slopePieces(route, records, startM, endM, stepM); } + +/** 계약 `PlanFootprint` + 비탈 끝 점마다 깎기 여부 · 눈금 · 측점 줄(다음 계산이 범위 밖을 재사용). */ +export interface FootprintLines extends PlanFootprint { + slopeLeftCut: boolean[]; + slopeRightCut: boolean[]; + ticks: Line[]; + records: FootprintRecord[]; +} + +/** 측점 줄 → 계약 꼴 평면 발자국(시점 → 종점 · 선은 `slopePieces` 와 같은 점). */ +export function footprintLines( + route: Array<[number, number, number]>, + records: FootprintRecord[], + startM: number, + endM: number, + stepM: number = TICK_STEP_M, +): FootprintLines { + const sampled = sampleLines(route, records, startM, endM, stepM); + if (!sampled) { + const empty = { roadLeft: [], roadRight: [], slopeLeft: [], slopeRight: [] }; + return { ...empty, slopeLeftCut: [], slopeRightCut: [], ticks: [], records }; + } + const { lines, cut, ticks } = sampled; + return { + roadLeft: lines.left_edge_m as Vertex[], + roadRight: lines.right_edge_m as Vertex[], + slopeLeft: lines.left_end_m as Vertex[], + slopeRight: lines.right_end_m as Vertex[], + slopeLeftCut: cut.left, + slopeRightCut: cut.right, + ticks, + records, + }; +} diff --git a/resources/tester/test_346_2_footprint_stage.py b/resources/tester/test_346_2_footprint_stage.py new file mode 100644 index 000000000..4691b4804 --- /dev/null +++ b/resources/tester/test_346_2_footprint_stage.py @@ -0,0 +1,181 @@ +"""346-2 footprint 단계 — cross · ground → 계약 `PlanFootprint` 꼴 · 바뀐 범위 밖 재사용. + +./venv/Scripts/python.exe -m pytest resources/tester/test_346_2_footprint_stage.py -q +""" + +from __future__ import annotations + +import copy +import json +import re +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 + side_is_cut, + slope_end, +) + +LOGIC = ROOT / "B05_Profile" / "logic" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +OFFSETS = [0.5 * i for i in range(1, 41)] + +DRIVER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { footprintStage } from "./B05_Profile_Footprint_Stage.js"; +const c = JSON.parse(readFileSync(process.argv[2], "utf-8")); +const ctx = (cross, range, prev) => ({ + generation: 1, range, signal: new AbortController().signal, inputs: {}, + out: { route: c.route, ground: { stations: c.stations, longitudinal: { chainageM: [], z: [] } }, + cross: { byKey: cross } }, + before: {}, prev, +}); +const first = footprintStage(ctx(c.cross, null, null)); +const second = footprintStage(ctx(c.cross2, c.range, first)); +const fresh = footprintStage(ctx(c.cross2, null, null)); +writeFileSync(process.argv[3], JSON.stringify({ first, second, fresh })); +""" + + +def _ground(o: float, k: int) -> float: + return 100.0 + (0.3 if k % 2 else -0.25) * o + + +def _detail(k: int, lift: float = 0.0) -> dict: + road = 100.0 + lift + line = [] + for o in [-20.0 + 0.5 * i for i in range(81)]: + if abs(o) <= 2.5: + line.append({"offset_m": o, "elevation_m": road}) + continue + g, edge_g = _ground(o, k), _ground(2.5 if o > 0 else -2.5, k) + run = abs(o) - 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}) + edge = {"offset_m": 2.5, "elevation_m": road} + return {"road_edges": {"left": edge, "right": {**edge, "offset_m": -2.5}}, "design_line": line} + + +def _case() -> dict: + stations, cross = [], {} + for k in range(6): + key = f"grid:{k}" + stations.append( + { + "key": key, + "chainageM": 20.0 * k, + "x": 20.0 * k, + "y": 0.0, + "azimuthDeg": 90.0, + "left": [0.0, 1.0], + "kind": "grid", + "id": None, + "ground": { + "centerZ": _ground(0.0, k), + "left": [{"offsetM": o, "z": _ground(o, k)} for o in OFFSETS], + # 우 = 중심에서 바깥 · offsetM 양수 · 한 칸 빈칸 + "right": [ + {"offsetM": o, "z": None if o == 15.0 else _ground(-o, k)} for o in OFFSETS + ], + }, + "planRadiusM": None, + "curveOuterSide": None, + "curveWideningM": None, + } + ) + cross[key] = {"key": key, "chainageM": 20.0 * k, "cutAreaM2": 0, "fillAreaM2": 0} + cross[key]["detail"] = _detail(k) + cross2 = copy.deepcopy(cross) + cross2["grid:1"]["detail"] = _detail(1, 2.0) # 범위 밖 — 앞 줄 그대로여야 함 + cross2["grid:4"]["detail"] = _detail(4, 2.0) # 범위 안 — 다시 셈 + route = {"line": [[0.0, 0.0], [100.0, 0.0]], "chainage": [0.0, 100.0], "lengthM": 100.0} + return { + "route": route, + "stations": stations, + "cross": cross, + "cross2": cross2, + "range": {"sources": ["grade"], "fromM": 75.0, "toM": 85.0, "keys": []}, + } + + +@pytest.fixture(scope="module") +def result(tmp_path_factory: pytest.TempPathFactory) -> dict: + out = tmp_path_factory.mktemp("ts_footprint_stage") + files = [ + str(LOGIC / "B05_Profile_Footprint_Stage.ts"), + str(LOGIC / "B05_Profile_PlanFootprint.ts"), + ] + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), *files, "--outDir", str(out), "--module", "esnext", "--target", "es2022", + "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", "--noResolve"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + stage = out / "B05_Profile_Footprint_Stage.js" + stage.write_text( + re.sub(r'from "\./B05_Profile_PlanFootprint"', 'from "./B05_Profile_PlanFootprint.js"', + stage.read_text(encoding="utf-8")), + encoding="utf-8", + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + (out / "in.json").write_text(json.dumps(_case()), 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 _by_key(lines: dict) -> dict: + return {record["key"]: record for record in lines["records"]} + + +def test_측점_줄은_B07_식과_같다(result) -> None: + """지반 꼴 바꿈(우 = 음수 오프셋 · 빈칸 = 무효)이 B07 slope_end · side_is_cut 입력과 같음.""" + case = _case() + got = _by_key(result["first"]) + for station in case["stations"]: + ground = station["ground"] + samples = ( + [ + {"offset_m": -s["offsetM"], "elevation_m": s["z"], "valid": s["z"] is not None} + for s in reversed(ground["right"]) + ] + + [{"offset_m": 0.0, "elevation_m": ground["centerZ"], "valid": True}] + + [{"offset_m": s["offsetM"], "elevation_m": s["z"], "valid": True} + for s in ground["left"]] + ) # fmt: skip + detail = case["cross"][station["key"]]["detail"] + record = got[station["key"]] + line = detail["design_line"] + assert record["left_end_m"] == pytest.approx(slope_end(line, samples, 2.5, "left")) + assert record["right_end_m"] == pytest.approx(slope_end(line, samples, -2.5, "right")) + assert record["left_cut"] == side_is_cut(samples, 2.5, 100.0) + assert record["right_cut"] == side_is_cut(samples, -2.5, 100.0) + + +def test_계약_꼴_선(result) -> None: + first = result["first"] + for name in ("roadLeft", "roadRight", "slopeLeft", "slopeRight"): + line = first[name] + assert len(line) == 51 # 0 ~ 100 m · 2 m + assert [p[0] for p in line] == sorted(p[0] for p in line) # 시점 → 종점 + assert all(p[1] == pytest.approx(2.5) for p in first["roadLeft"]) + assert all(p[1] == pytest.approx(-2.5) for p in first["roadRight"]) + assert len(first["slopeLeftCut"]) == 51 and len(first["slopeRightCut"]) == 51 + assert any(first["slopeLeftCut"]) and not all(first["slopeLeftCut"]) # 깎기 · 쌓기 섞임 + + +def test_범위_밖은_앞_줄_재사용(result) -> None: + first, second, fresh = (_by_key(result[k]) for k in ("first", "second", "fresh")) + assert second["grid:1"] == first["grid:1"] # 범위 밖 — 고친 횡단이어도 다시 안 셈 + assert second["grid:1"] != fresh["grid:1"] + assert second["grid:4"] == fresh["grid:4"] # 범위 안 — 새 값 + assert second["grid:4"] != first["grid:4"] diff --git a/resources/tester/test_346_2_plan_footprint_mirror.py b/resources/tester/test_346_2_plan_footprint_mirror.py index e1a26ef04..9be348692 100644 --- a/resources/tester/test_346_2_plan_footprint_mirror.py +++ b/resources/tester/test_346_2_plan_footprint_mirror.py @@ -142,7 +142,8 @@ 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"], + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", + "--noResolve"], cwd=str(ROOT), check=True, capture_output=True, ) # fmt: skip (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") From abd8037e107b4bd804c991ee5b3725065fe688d8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:37:11 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(=EA=B3=84=EC=82=B0=EB=AC=B6=EC=9D=8C):?= =?UTF-8?q?=20346-1=20route=20=EB=8B=A8=EA=B3=84=20=EA=B3=A1=EC=84=A0?= =?UTF-8?q?=ED=91=9C=20=EC=B1=84=EC=9B=80=20=E2=80=94=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20RouteLine.curves(start=20=C2=B7=20apex=20=C2=B7=20end=20?= =?UTF-8?q?=C2=B7=20radius=5Fm)=20=3D=20buildEditedPolyline=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=ED=91=9C=20=C2=B7=20savedLine=20=EB=8F=84=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=ED=91=9C=20=ED=95=A8=EA=BB=98=20=C2=B7=20typecheck=20?= =?UTF-8?q?=EB=B9=A8=EA=B0=95=20=ED=92=80=EB=A6=BC=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=EC=84=9C=EB=B2=84=20=EA=B3=A1=EC=84=A0=ED=91=9C?= =?UTF-8?q?=EC=99=80=20=EA=B0=99=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../logic/B05_Profile_Pipeline_RouteGround.ts | 29 ++++++++++++------- .../tester/test_346_1_route_ground_stage.py | 11 ++++++- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts index 47ec5b627..b9e3d0d88 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts @@ -13,6 +13,7 @@ * ========================================================================== */ import type { + DesignCurve, GroundOutput, GroundSample, PipelineStation, @@ -38,35 +39,43 @@ import { export interface RouteStageDeps { /** 곡선 기본 반지름(m) — 편집기 `minRadiusM`(계획 기준 · 0 = 없음). */ minRadiusM(): number; - /** 초안이 없을 때(= 저장분 그대로) 그릴 노선 — 없으면 null. */ - savedLine(): Vertex[] | null; + /** 초안이 없을 때(= 저장분 그대로) 그릴 노선 · 곡선표 — 없으면 null. */ + savedLine(): { line: Vertex[]; curves: DesignCurve[] } | null; } /** 정점마다 누가거리 — 첫 점 0. */ -export function routeLineOf(line: Vertex[]): RouteLine { +export function routeLineOf(line: Vertex[], curves: DesignCurve[] = []): RouteLine { const chainage = [0]; for (let i = 1; i < line.length; i += 1) { chainage.push( chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]), ); } - return { line, chainage, lengthM: chainage[chainage.length - 1] }; + return { line, curves, chainage, lengthM: chainage[chainage.length - 1] }; } -/** 초안 → 그린 노선. 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기 `markEdited` 와 같은 차례). */ -export function draftLine(draft: RouteDraft, minRadiusM: number): Vertex[] { +/** 초안 → 그린 노선 · 곡선표(계약 꼴). 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기 + * `markEdited` 와 같은 차례). */ +export function draftLine( + draft: RouteDraft, + minRadiusM: number, +): { line: Vertex[]; curves: DesignCurve[] } { const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]); const radius = [...draft.curveRadius]; applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius); - return buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM).vertices; + const built = buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM); + return { + line: built.vertices, + curves: built.curves.map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m })), + }; } export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> { return (ctx) => { const draft = ctx.inputs.route; - const line = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine(); - if (!line || line.length < 2) throw new Error("그릴 노선이 없습니다."); - return routeLineOf(line); + const drawn = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine(); + if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다."); + return routeLineOf(drawn.line, drawn.curves); }; } diff --git a/resources/tester/test_346_1_route_ground_stage.py b/resources/tester/test_346_1_route_ground_stage.py index 9f25a8298..0c2c3f8e2 100644 --- a/resources/tester/test_346_1_route_ground_stage.py +++ b/resources/tester/test_346_1_route_ground_stage.py @@ -49,7 +49,9 @@ const draft = { rev: 1, nodes: job.nodes, curveOn: Array(n).fill(true), curveRadius: Array(n).fill(null), curveLock: Array(n).fill(null), curveArc: Array(n).fill(null), stationSnap: false, terrain: null, }; -const routeStage = createRouteStage({ minRadiusM: () => job.minRadius, savedLine: () => job.line }); +const routeStage = createRouteStage({ + minRadiusM: () => job.minRadius, savedLine: () => ({ line: job.line, curves: [] }), +}); const ctxOf = (extra) => ({ generation: 1, range: null, signal: new AbortController().signal, inputs: { route: null, alignment: null, cross: null, pipes: null, structures: null }, @@ -242,7 +244,14 @@ def test_route_ground_단계가_서버와_같고_범위_밖을_재사용한다(t steps = np.hypot(*np.diff(np.asarray(built.vertices), axis=0).T) assert abs(route["lengthM"] - float(steps.sum())) < 1e-6 assert route["chainage"][0] == 0 and len(route["chainage"]) == len(route["line"]) + assert len(route["curves"]) == len(built.curves) > 0 + for ts, py in zip(route["curves"], built.curves, strict=True): + assert set(ts) == {"start", "apex", "end", "radius_m"} + assert abs(ts["radius_m"] - py.radius_m) < 1e-9 + for key in ("start", "apex", "end"): + assert max(abs(a - b) for a, b in zip(ts[key], getattr(py, key))) < 1e-9 assert out["saved"]["line"] == line + assert out["saved"]["curves"] == [] assert out["noRoute"] # ground 브라우저 길 = 서버 generate_sections(측점 · 횡단 · 종단) From 31a5912833db389d2640875d5ba9d65b36da34c0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:37:57 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(=EC=A7=80=ED=98=95=ED=91=9C=EB=B3=B8):?= =?UTF-8?q?=20346-1=20=EC=A7=80=EB=B0=98=EA=B3=A0=20=EC=9E=90=EB=A6=BF?= =?UTF-8?q?=EC=88=98=20=EC=B9=B8=20=E2=80=94=20/route/elevations=20decimal?= =?UTF-8?q?s(=EA=B8=B0=EB=B3=B8=203=20=C2=B7=200~6)=20=C2=B7=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=AC=B6=EC=9D=8C=20=EC=84=9C=EB=B2=84=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=80=206=EC=9E=90=EB=A6=AC=EB=A1=9C=20=EB=B6=80=EB=A6=84(?= =?UTF-8?q?=EA=B2=A9=EC=9E=90=20=EA=B8=B8=EA=B3=BC=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B0=92)=20=C2=B7=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_Api_TerrainGrid.ts | 4 +-- B05_Profile/B05_Profile_Router_Terrain.py | 6 ++++- .../tester/test_344_1_terrain_grid_route.py | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/B05_Profile/B05_Profile_Api_TerrainGrid.ts b/B05_Profile/B05_Profile_Api_TerrainGrid.ts index 647f5dcad..14d5387c6 100644 --- a/B05_Profile/B05_Profile_Api_TerrainGrid.ts +++ b/B05_Profile/B05_Profile_Api_TerrainGrid.ts @@ -73,7 +73,7 @@ export function designTerrainGridNow(projectId: string): DesignTerrainGrid | nul /** 한 번에 물을 수 있는 점 수 — 서버 `MAX_POINTS` 와 같은 값. */ const ELEVATION_CHUNK = 4000; -/** 계산 묶음 ground 단계에 넣을 격자 · 서버 길(PLAN 346-1). 서버 길 값은 mm 자리(응답 그대로). */ +/** 계산 묶음 ground 단계에 넣을 격자 · 서버 길(PLAN 346-1). 서버 길도 소수 6자리 — 격자 길과 같은 값. */ export function groundStageDeps(projectId: string): Pick { return { grid: () => designTerrainGridNow(projectId), @@ -88,7 +88,7 @@ export function groundStageDeps(projectId: string): Pick return { "status": "success", "project_id": str(project_id), - "z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)], + "z": [ + None if not ok else round(float(value), request.decimals) for value, ok in zip(z, valid) + ], "valid": [bool(ok) for ok in valid], } diff --git a/resources/tester/test_344_1_terrain_grid_route.py b/resources/tester/test_344_1_terrain_grid_route.py index 69476e550..05c363bc3 100644 --- a/resources/tester/test_344_1_terrain_grid_route.py +++ b/resources/tester/test_344_1_terrain_grid_route.py @@ -114,3 +114,30 @@ def test_받을_자리_서명은_manifest_구성과_평활(tmp_path): ) assert terrain_router._design_grid({**PARAMS, "method": "tin"}, models, confirmed) is None assert terrain_router._design_grid(PARAMS, models, None) is None + + +def test_지반고_자릿수_기본_3_묶음은_6(tmp_path, monkeypatch): + root = tmp_path + models = root / "B04_PreProcess" / "models" + models.mkdir(parents=True) + x = np.arange(0.0, 11.0) + np.savez( + models / "dtm_csf_smooth.npz", + x=x, + y=x, + z=np.add.outer(x * 0.0, x * 0.1234567), + valid_mask=np.ones((11, 11), dtype=bool), + ) + _patch(monkeypatch, root, None) + + async def params(_connection, _project_id): + return dict(PARAMS) + + monkeypatch.setattr(terrain_router, "get_surface_confirmation_params", params) + ask = terrain_router.ElevationRequest + plain = asyncio.run(terrain_router.read_route_elevations(PID, ask(points=[(5.5, 5.0)]))) + fine = asyncio.run( + terrain_router.read_route_elevations(PID, ask(points=[(5.5, 5.0)], decimals=6)) + ) + assert plain["z"] == [round(5.5 * 0.1234567, 3)] + assert fine["z"] == [round(5.5 * 0.1234567, 6)]