Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-19 14:46:35 +09:00
23 changed files with 729 additions and 72 deletions
@@ -38,7 +38,7 @@ const R2 = 8;
const raw = (name, route) => m.readState(name, P, route) ?? {};
const keysOf = (name, route) =>
Object.keys(raw(name, route))
.filter((k) => k !== "@at")
.filter((k) => !k.startsWith("@")) // 「@at」 · 「@seat」 = 쓴 때 자리 칸(475-3)
.sort();
const section = (chainage_m, kind, extra = {}) => ({
station_id: `s${chainage_m}`,
@@ -173,7 +173,7 @@ const NEW = (dy) => [
.sort((a, b) => a[0] - b[0])
.map(([at, c]) => [at, c.ground_type, c.section_mode ?? null]),
rawKeys: Object.keys(m.readState("crossdesign", P, R) ?? {})
.filter((k) => k !== "@at")
.filter((k) => !k.startsWith("@"))
.sort(),
widths: m.readState("crossw", P, R),
redraws,
+2 -2
View File
@@ -97,10 +97,10 @@ const snap = () => ({
.sort((a, b) => a[0] - b[0])
.map(([at, c]) => [at, c.ground_type ?? null, c.section_mode ?? null]),
shoulder: Object.entries(m.readState("shoulderext", P, R) ?? {})
.filter(([k]) => k !== "@at")
.filter(([k]) => !k.startsWith("@"))
.map(([k, v]) => [k, v]),
berm: Object.entries(m.readState("bermdepth", P, R) ?? {})
.filter(([k]) => k !== "@at")
.filter(([k]) => !k.startsWith("@"))
.map(([k, v]) => [k, v]),
});
+1 -1
View File
@@ -38,7 +38,7 @@ const R2 = 8;
const raw = (name, route) => m.readState(name, P, route) ?? {};
const keysOf = (name, route) =>
Object.keys(raw(name, route))
.filter((k) => k !== "@at")
.filter((k) => !k.startsWith("@")) // 「@at」 · 「@seat」 = 쓴 때 자리 칸(475-3)
.sort();
const section = (chainage_m, kind, extra = {}) => ({
station_id: `s${chainage_m}`,
@@ -6,7 +6,7 @@ const ROOT = path.resolve(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`;
const REAL =
/(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_\w+)$/;
/(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|B06_Section_Route_Carry|design_pipeline_cross|common_util_\w+)$/; // Route_Carry = 자리 칸 「@seat」(475-3)
const CATALOG = require("./helper_322_catalog_module.cjs");
const STUBS = {
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
+5
View File
@@ -0,0 +1,5 @@
/* 475-3 시험 묶음 진입점 — 자리 칸(암 경계 · 2단 경사 · 손 상단측) 넘김 규칙을 화면 · 서버 없이 잰다. */
export { sameSeat } from "../../common_util/common_util_station_key";
export { carryKeyedStates } from "../../B06_Section/logic/B06_Section_Route_Carry";
export { crossAtSeat } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
export { swapCrossSections } from "../../B06_Section/logic/B06_Section_Station_Swap";
@@ -0,0 +1,135 @@
/* 475-3 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_475_3_entry.ts)을 부르고 결과를 JSON 한 벌로.
* node helper_475_3_seat_carry.mjs <묶음.mjs> <입력.json>
* 입력 = { seats: [[a, b], …] }(자리 판정 거울 사례).
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const [bundle, inputPath] = process.argv.slice(2);
const m = await import(pathToFileURL(bundle).href);
const input = JSON.parse(readFileSync(inputPath, "utf-8"));
const out = {};
out.sameSeat = input.seats.map(([a, b]) => m.sameSeat(a, b));
/* 노선 바뀜 넘김 — grid:1 같은 자리 · grid:2 옮김 · grid:3 옮겼지만 값은 새 자리에서 씀(되돌리기로 되살린 값) */
const A = { x: 0, y: 20, left: [-1, 0] };
const B = { x: 0, y: 40, left: [-1, 0] };
const C = { x: 0, y: 60, left: [-1, 0] };
const C2 = { x: 3, y: 60, left: [-1, 0] };
const from = [
{ key: "grid:1", chainageM: 20, seat: A },
{ key: "grid:2", chainageM: 40, seat: B },
{ key: "grid:3", chainageM: 60, seat: C },
];
const to = [
{ key: "grid:1", chainageM: 20, seat: A },
{ key: "grid:2", chainageM: 40.5, seat: { x: 5, y: 40, left: [-1, 0] } },
{ key: "grid:3", chainageM: 61, seat: C2 },
];
const choice = {
ground_type: "soil",
ditch_type: "l_type",
two_stage_slope: true,
section_mode: "right_cut",
ditch_side: "left",
paved: true,
};
const raw = {
rockb: { "grid:1": 1.2, "grid:2": 1.5, "grid:3": 0.9, "@seat": { "grid:3": C2 } },
cutslope: { "grid:1": 0.8, "grid:2": 0.7 },
crossdesign: { "grid:1": choice, "grid:2": choice },
shoulderext: { "grid:2": { left: 0.5 } },
bermdepth: {},
};
const strip = (states) =>
Object.fromEntries(
Object.entries(states).map(([name, cells]) => [
name,
Object.fromEntries(Object.entries(cells).filter(([key]) => !key.startsWith("@"))),
]),
);
const carried = m.carryKeyedStates((name) => raw[name] ?? {}, from, to);
out.carried = strip(carried.states);
out.carriedSeats = carried.states.rockb["@seat"];
out.outside = carried.outside.size;
/* 자리 모름(옛 목록) = 옛 동작 그대로 */
const bare = (list) => list.map(({ key, chainageM }) => ({ key, chainageM }));
out.unknown = strip(m.carryKeyedStates((name) => raw[name] ?? {}, bare(from), bare(to)).states);
/* 미리보기 입력 — 저장분 · 세션 자리 칸 묶음(`seats`) */
const station = (key, x, y) => ({ key, chainageM: y, x, y, left: [-1, 0] });
const input2 = {
standard: {},
byKey: {
"grid:1": {
groundType: "soil",
rockBoundaryOffsetM: 1,
sectionMode: "right_cut",
seats: [{ ...A, fields: ["rockBoundaryOffsetM", "sectionMode"] }],
},
"grid:2": {
groundType: "soil",
twoStageSlope: true,
cutSlopeRatio: 0.7,
seats: [
{ ...B, fields: ["twoStageSlope"] },
{ x: 5, y: 40, left: [-1, 0], fields: ["cutSlopeRatio"] },
],
},
},
};
out.atSeat = m.crossAtSeat(input2, [station("grid:1", 0, 20), station("grid:2", 5, 40)]).byKey;
/* B06 갈아끼움(노선 초안) — 앞 횡단 설계를 이어 받되 자리가 바뀐 측점은 자리 칸 기본값 */
const hand = {
ground_type: "soil",
section_mode: "both_cut",
ditch_side: "right",
two_stage_slope: true,
rock_boundary_offset_m: -1.2,
cut_slope_ratio_user: 0.8,
shoulder_extend_left_m: 0.5,
};
const section = (chainage, x) => ({
station_id: `s${chainage}`,
chainage_m: chainage,
kind: "regular",
center_x: x,
center_y: chainage,
frame: { left_xy: [-1, 0] },
samples: [],
uphill_side: "left",
design: { ...hand },
});
const pipelineStation = (key, chainage, x) => ({
key,
kind: "grid",
chainageM: chainage,
x,
y: chainage,
left: [-1, 0],
azimuthDeg: 0,
planRadiusM: null,
curveOuterSide: null,
curveWideningM: 0,
ground: null,
});
const detail = {
longitudinal: { stations: [], length_m: 40 },
cross_sections: [section(20, 0), section(40, 0)],
};
const swapped = (draft) =>
m
.swapCrossSections(
detail,
[pipelineStation("grid:1", 20, 0), pipelineStation("grid:2", 40, 5)],
["grid:1", "grid:2"],
20,
draft,
)
.detail.cross_sections.map((s) => s.design);
out.swapDraft = swapped(true);
out.swapNoDraft = swapped(false);
process.stdout.write(JSON.stringify(out));
@@ -97,11 +97,15 @@ def test_노선_바뀜_측점_열쇠로_넘김(out):
assert out["outside"] == 2 # 60 m(노선이 짧아짐) · 99 m(옛 노선에도 없던 옛 초안)
assert out["carriedKeys"] == ["grid:1", "grid:2", "pipe:pipe-s1", "structure:st-1"]
assert out["carriedRead"] == [20, 40, 47, 55] # 관 · 구조물은 옮긴 자리로
# 자리 칸(2단 경사 · 손 상단측) = 같은 자리만(475-3) — 이 측점들은 좌표가 없어(자리 모름)
# 같은 자리로 봄 · 옛 단언(단면 유형 · 측구 방향은 늘 새 지형)을 고침 · 포장은 안 넘김
assert out["carriedChoice"] == {
"ground_type": "soil",
"ditch_type": "l_type",
"ditch_choice": False,
"two_stage_slope": True,
"section_mode": "right_cut",
"ditch_side": "right",
}
assert out["carriedRock"] == {"20.00": -0.8}
assert out["carriedShoulder"] == ["grid:2"] # 사면 쉬프트도 넘김(417)
+3 -1
View File
@@ -97,7 +97,9 @@ def test_넘김_값_사용자_칸(out, carried) -> None:
by = _by_chainage(carried["patches"])
assert by["20.00"]["ground_type"] == "soil" and by["20.00"]["two_stage_slope"] is True
assert by["20.00"]["ditch_type"] == "u_flume"
assert "section_mode" not in by["20.00"] # 단면 유형 = 새 지형이 정함
# 손 단면 유형 = 자리 칸(475-3) — 이 목록은 자리를 몰라(옛 목록) 같은 자리로 봄 · 넘김
# (옛 단언 = 늘 새 지형)
assert by["20.00"]["section_mode"] == "left_cut"
assert by["20.00"]["structure_note"] == "앞 메모"
assert by["40.00"]["shoulder_extend_left_m"] == 0.5
assert "revet_adjust" not in by["40.00"] # 일반 측점 옹벽 조절 = 안 넘김(관 · 구조물 열쇠만)
+237
View File
@@ -0,0 +1,237 @@
# -*- coding: utf-8 -*-
"""475-3(요구 275-3) — 노선이 바뀌면 자리 칸은 같은 자리 측점만 들고 감.
자리 칸 = 암 경계 · 2단 경사 · 손 상단측(TS `SEAT_KEYS` 하나 · 파이썬은 그 글을 읽음).
못박는 것
- 자리 판정 거울 — TS `sameSeat` ↔ 파이썬 `same_seat`(중심 0.01 m · 왼쪽 축 1e-3 ·
모르면 같은 자리).
- 브라우저 · Node 넘김(`carryKeyedStates`) — 같은 자리 = 자리 칸도 · 옮긴 자리 = 자리 칸
버림(새 자리 기본값) · 지반 · 측구 종류 · 사면 쉬프트는 그대로 · 값의 쓴 때 자리(`@seat` ·
되돌리기로 되살린 값)가 새 자리와 같으면 유지 · 자리 모르는 옛 목록 = 옛 동작.
- 미리보기 입력(`crossAtSeat`) — 자리 칸 묶음이 측점 자리와 다르면 뺌 · 같으면 그대로.
- 서버 노선 저장 넘김(`carry_cross_inputs` · `seats`) — 같은 규칙 · `seats` 없으면 옛 동작.
- 기본 설계가 넘긴 손 단면 유형으로 셈(`compute_default_designs` · 없으면 상단측 깎기).
./venv/Scripts/python.exe -m pytest resources/tester/test_475_3_seat_carry.py -q
"""
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file # noqa: E402
from B05_Profile.B05_Profile_Route_Save_Parts import carry_cross_inputs, station_seats # noqa: E402
from B06_Section.B06_Section_Router_Design import ( # noqa: E402
CARRY_INPUT_KEYS,
SEAT_KEYS,
compute_default_designs,
)
from common_util.common_util_station_key import same_seat, station_seat # noqa: E402
SEATS = [
[{"x": 0, "y": 0, "left": [1, 0]}, {"x": 0.009, "y": 0, "left": [1, 0]}],
[{"x": 0, "y": 0, "left": [1, 0]}, {"x": 0.011, "y": 0, "left": [1, 0]}],
[{"x": 0, "y": 0, "left": [1, 0]}, {"x": 0, "y": 0, "left": [0.998, 0.002]}],
[{"x": 0, "y": 0, "left": [1, 0]}, {"x": 0, "y": 0, "left": [0.9995, 0.0009]}],
[{"x": 0, "y": 0, "left": None}, {"x": 0, "y": 0.005, "left": [0, 1]}],
[None, {"x": 9, "y": 9, "left": [0, 1]}],
]
def _py_seat(seat):
if seat is None:
return None
left = seat.get("left")
return seat["x"], seat["y"], tuple(left) if left else None
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b475_3")
build = subprocess.run(
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_475_3_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert build.returncode == 0, build.stderr
bundle = next(work.glob("*.js"), None) or next(work.glob("*.mjs"))
inputs = work / "input.json"
inputs.write_text(json.dumps({"seats": SEATS}), "utf-8")
proc = subprocess.run(
["node", str(HERE / "helper_475_3_seat_carry.mjs"), str(bundle), str(inputs)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_자리_칸_목록_정본은_TS_하나():
swap = (ROOT / "B06_Section" / "logic" / "B06_Section_Station_Swap.ts").read_text("utf-8")
body = re.search(r"export const SEAT_KEYS = \[(.*?)\] as const;", swap, re.S)
assert tuple(re.findall(r'"([a-z_0-9]+)"', body.group(1))) == SEAT_KEYS
assert SEAT_KEYS == (
"two_stage_slope",
"rock_boundary_offset_m",
"cut_slope_ratio_user",
"section_mode",
"ditch_side",
)
def test_자리_판정_거울(out):
assert out["sameSeat"] == [same_seat(_py_seat(a), _py_seat(b)) for a, b in SEATS]
assert out["sameSeat"] == [True, False, False, True, True, True]
def test_같은_자리는_자리_칸도_옮긴_자리는_기본값(out):
carried = out["carried"]
assert carried["rockb"] == {
"grid:1": 1.2,
"grid:3": 0.9,
} # grid:2 옮김 = 버림 · grid:3 = 쓴 때 자리
assert carried["cutslope"] == {"grid:1": 0.8}
assert carried["crossdesign"]["grid:1"] == { # 포장은 안 넘김(138-1)
"ground_type": "soil",
"ditch_type": "l_type",
"two_stage_slope": True,
"section_mode": "right_cut",
"ditch_side": "left",
}
assert carried["crossdesign"]["grid:2"] == {"ground_type": "soil", "ditch_type": "l_type"}
assert carried["shoulderext"] == {"grid:2": {"left": 0.5}} # 자리 칸 아님 = 그대로
assert out["carriedSeats"]["grid:3"] == {"x": 3, "y": 60, "left": [-1, 0]}
assert out["outside"] == 0
def test_자리_모르는_옛_목록은_옛_동작(out):
unknown = out["unknown"]
assert unknown["rockb"] == {"grid:1": 1.2, "grid:2": 1.5, "grid:3": 0.9}
assert unknown["crossdesign"]["grid:2"]["two_stage_slope"] is True
def test_미리보기_입력_자리_칸(out):
at = out["atSeat"]
assert at["grid:1"] == {
"groundType": "soil",
"rockBoundaryOffsetM": 1,
"sectionMode": "right_cut",
}
# grid:2 = 저장 자리(0, 40)에서 온 2단 경사는 뺌 · 새 자리(5, 40)에서 쓴 암 경사는 그대로
assert at["grid:2"] == {"groundType": "soil", "cutSlopeRatio": 0.7}
def test_B06_갈아끼움_자리_바뀐_측점은_자리_칸_기본값(out):
same, moved = out["swapDraft"]
assert same["rock_boundary_offset_m"] == -1.2 and same["section_mode"] == "both_cut"
assert moved == {
"ground_type": "soil",
"section_mode": "left_cut",
"shoulder_extend_left_m": 0.5,
}
# 노선 초안 아닌 차례(관 · 구조물 · 추가 측점만) = 이어 받은 설계 그대로
assert out["swapNoDraft"][1]["two_stage_slope"] is True
def _row(chainage, x, left=(-1.0, 0.0)):
return {
"chainage_m": chainage,
"center_x": x,
"center_y": chainage,
"frame": {"left_xy": list(left)},
}
def test_서버_노선_저장_넘김_같은_규칙():
old_keys = {"grid:1": 20.0, "grid:2": 40.0}
new_keys = {"grid:1": 20.0, "grid:2": 40.5}
old_rows = [_row(20.0, 0.0), _row(40.0, 0.0)]
new_rows = [_row(20.0, 0.0), _row(40.5, 5.0)]
seats = (station_seats(old_rows, old_keys), station_seats(new_rows, new_keys))
hand = {
"ground_type": "soil",
"two_stage_slope": True,
"rock_boundary_offset_m": 1.5,
"cut_slope_ratio_user": 0.8,
"section_mode": "right_cut",
"ditch_side": "left",
"shoulder_extend_left_m": 0.5,
}
designs = {20.0: dict(hand), 40.0: dict(hand)}
keys = (*CARRY_INPUT_KEYS, "shoulder_extend_left_m")
inputs, _, dropped = carry_cross_inputs(old_keys, new_keys, designs, keys, (), SEAT_KEYS, seats)
assert inputs[20.0] == hand
assert inputs[40.5] == {"ground_type": "soil", "shoulder_extend_left_m": 0.5}
assert dropped == 0
# seats 없음 = 옛 동작(손 상단측은 안 넘김 · 자리 칸 셋은 넘김)
old, _, _ = carry_cross_inputs(old_keys, new_keys, designs, keys, ())
assert old[40.5] == {k: v for k, v in hand.items() if k not in ("section_mode", "ditch_side")}
assert station_seat({"chainage_m": 1}) is None
def test_기본_설계가_넘긴_손_단면_유형으로_셈(tmp_path, monkeypatch):
"""엔진 입력 = 넘긴 단면 유형 · 측구 방향(결과 단면 유형은 엔진이 지형으로 다시 판정)."""
import B06_Section.B06_Section_Router_Design as design_module
seen: list[tuple[str, object]] = []
real = design_module.compute_cross_design
def spy(*args, **kwargs):
seen.append((kwargs["section_mode"], kwargs.get("ditch_side")))
return real(*args, **kwargs)
monkeypatch.setattr(design_module, "compute_cross_design", spy)
route = tmp_path / "routes" / "r1"
(route / "longitudinal").mkdir(parents=True)
(route / "cross_sections").mkdir()
profile = [
{"chainage_m": 0.0, "elevation_m": 100.0},
{"chainage_m": 40.0, "elevation_m": 100.0},
]
relative = "routes/r1/longitudinal/longitudinal.json"
stations = [{"chainage_m": 20.0, "uphill_side": "left"}]
(tmp_path / relative).write_text(
json.dumps({"stations": stations, "design_profiles": [{"samples": profile}]}), "utf-8"
)
samples = [
{"offset_m": float(o), "elevation_m": 100.0 + 0.3 * o, "valid": True}
for o in range(-15, 16)
]
cross = {"chainage_m": 20.0, "samples": samples, "uphill_side": "left"}
find_cross_file(route / "cross_sections", 20.0).write_text(json.dumps(cross), "utf-8")
plain = compute_default_designs(tmp_path, relative, [20.0])
hand = compute_default_designs(
tmp_path,
relative,
[20.0],
carried={20.0: {"section_mode": "both_cut", "ditch_side": "right"}},
)
assert plain and hand
assert seen == [("left_cut", None), ("both_cut", "right")]