feat(B06): 보라 계획선 이동 축 제약 — 사면 = 그 쪽 바깥 가로만 · 노면 = 높이만(193-1)
- 절토 · 성토측(구조물 트림 선 포함) 고름 = ◀▶ 그 쪽 노견째 · ▲▼ 막음(종단 계획고로 새지 않음) - 도로측(노면 · 노견) 고름 = ◀▶ 막음 · 이동 줄 숨김 · ▲▼ 는 종단도 계획고로 그대로 넘김 - 새 시험 test_193_1_line_axis(갈래 · 바깥만 · 0 에서 멈춤 · 반대측 고정 · 절토 대칭 · 도로 Y 만) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
@@ -8,13 +8,15 @@
|
||||
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
|
||||
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
|
||||
* 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 ·
|
||||
* 노폭 안쪽 금지 = 0 에서 멈춤).
|
||||
* 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 ·
|
||||
* 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign } from "./B06_Section_Api_Fetch";
|
||||
import { clipSegment, type XY } from "../A00_Common/b_polygon_clip";
|
||||
import { sideRole } from "@util/common_util_cross_design_geometry";
|
||||
import {
|
||||
arrowsBelongToProfile,
|
||||
bindArrowKeys,
|
||||
buildPanelShell,
|
||||
dpadButton,
|
||||
@@ -100,6 +102,11 @@ export function crossZoneAt(
|
||||
export const pieceZone = (design: ZoneDesign, piece: DesignPiece) =>
|
||||
crossZoneAt(design, (piece.from.offset + piece.to.offset) / 2, piece.structure);
|
||||
|
||||
/** 고른 선이 움직이는 축(193-1) — 도로측(노면 · 노견) = 높이(y · 종단 계획고 ▲▼) ·
|
||||
* 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */
|
||||
export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): "x" | "y" =>
|
||||
pieceZone(design, piece).zone === "road" ? "y" : "x";
|
||||
|
||||
const gap = (a: CrossPoint, b: CrossPoint): number =>
|
||||
Math.hypot(a.offset - b.offset, a.elevation - b.elevation);
|
||||
|
||||
@@ -330,6 +337,7 @@ export function attachDesignLinePick(input: {
|
||||
label.className = "b06-cross-card__line-dims";
|
||||
label.hidden = true;
|
||||
let side: CrossSide | null = null;
|
||||
let axis: "x" | "y" = "x";
|
||||
let active: number | null = null;
|
||||
const { shift } = input;
|
||||
|
||||
@@ -364,6 +372,20 @@ export function attachDesignLinePick(input: {
|
||||
);
|
||||
shell.root.append(readout, move.row);
|
||||
const arrows = bindArrowKeys(shell.root, () => [move.controls]);
|
||||
// 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감.
|
||||
// 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김.
|
||||
const blockOffAxis = (event: KeyboardEvent): void => {
|
||||
if (!shell.root.isConnected) {
|
||||
document.removeEventListener("keydown", blockOffAxis);
|
||||
return;
|
||||
}
|
||||
if (shell.root.classList.contains("is-hidden") || arrowsBelongToProfile()) return;
|
||||
const focus = event.target as HTMLElement | null;
|
||||
if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return;
|
||||
const vertical = event.key === "ArrowUp" || event.key === "ArrowDown";
|
||||
const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
|
||||
if (axis === "x" ? vertical : horizontal) event.preventDefault();
|
||||
};
|
||||
|
||||
return {
|
||||
pieces,
|
||||
@@ -375,16 +397,25 @@ export function attachDesignLinePick(input: {
|
||||
label.hidden = !piece;
|
||||
active = piece ? index : null;
|
||||
side = piece ? pieceZone(design, piece).side : null;
|
||||
const extend = side && shift ? shift.valueOf(side) : 0;
|
||||
axis = piece ? pieceMoveAxis(design, piece) : "x";
|
||||
const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0;
|
||||
label.textContent = piece
|
||||
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
|
||||
: "";
|
||||
const open = !!(piece && shift);
|
||||
shell.root.classList.toggle("is-hidden", !open);
|
||||
shell.title.textContent = side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)";
|
||||
readout.textContent = `이동 ${extend.toFixed(1)}m`;
|
||||
move.row.classList.toggle("is-hidden", axis === "y");
|
||||
move.controls.classList.toggle("is-hidden", axis === "y");
|
||||
shell.title.textContent =
|
||||
axis === "y" ? "노면 높이" : side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)";
|
||||
readout.textContent =
|
||||
axis === "y" ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" : `이동 ${extend.toFixed(1)}m`;
|
||||
arrows.detach();
|
||||
if (open) arrows.attach();
|
||||
document.removeEventListener("keydown", blockOffAxis);
|
||||
if (open) {
|
||||
arrows.attach();
|
||||
document.addEventListener("keydown", blockOffAxis);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/* 139-6 고른 설계선 사면 이동 헬퍼 — `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서
|
||||
* 트랜스파일해 흉내 DOM 으로 돌림 · 151-3 이동 패널(◀ ↺ ▶ · 방향키 ←→). 짝 = `test_139_6_line_shift.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
class Node {
|
||||
constructor(tag) {
|
||||
this.tagName = tag;
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.listeners = {};
|
||||
this.hidden = false;
|
||||
this.isConnected = true;
|
||||
this._text = "";
|
||||
this._cls = new Set();
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...n) => n.forEach((c) => self._cls.add(c)),
|
||||
toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)),
|
||||
contains: (c) => self._cls.has(c),
|
||||
};
|
||||
}
|
||||
get descendants() {
|
||||
return this.children.flatMap((c) => [c, ...c.descendants]);
|
||||
}
|
||||
/** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */
|
||||
querySelector(sel) {
|
||||
return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null;
|
||||
}
|
||||
getClientRects() {
|
||||
return this._cls.has("is-hidden") ? [] : [{}];
|
||||
}
|
||||
set className(v) {
|
||||
this._cls = new Set(String(v).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
set textContent(v) {
|
||||
this._text = String(v);
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
insertBefore(kid) {
|
||||
this.children.push(kid);
|
||||
}
|
||||
setAttribute(n, v) {
|
||||
this.attrs[n] = String(v);
|
||||
if (n === "class") this.className = v;
|
||||
}
|
||||
getAttribute(n) {
|
||||
return this.attrs[n] ?? null;
|
||||
}
|
||||
hasAttribute(n) {
|
||||
return n in this.attrs;
|
||||
}
|
||||
addEventListener(t, fn) {
|
||||
(this.listeners[t] ??= []).push(fn);
|
||||
}
|
||||
click() {
|
||||
(this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined }));
|
||||
}
|
||||
}
|
||||
const docListeners = {};
|
||||
globalThis.document = {
|
||||
createElement: (t) => new Node(t),
|
||||
createElementNS: (_n, t) => new Node(t),
|
||||
addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn),
|
||||
removeEventListener: (t, fn) => {
|
||||
docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn);
|
||||
},
|
||||
};
|
||||
const key = (name) =>
|
||||
[...(docListeners.keydown ?? [])].forEach((fn) =>
|
||||
fn({ key: name, target: null, preventDefault: () => undefined }),
|
||||
);
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const req = (r) =>
|
||||
r.startsWith("@util/")
|
||||
? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`))
|
||||
: loadTs(path.join(path.dirname(full), `${r}.ts`));
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
globalThis.Element = Node;
|
||||
|
||||
const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts"));
|
||||
const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽
|
||||
const y = (e) => 900 - (e - 100) * 12;
|
||||
const line = (pts, structure = false) => {
|
||||
const el = new Node("polyline");
|
||||
el.setAttribute("class", "b06-chart__design-cross");
|
||||
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
|
||||
if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, "");
|
||||
return el;
|
||||
};
|
||||
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
|
||||
const press = (name) => {
|
||||
let prevented = false;
|
||||
[...(docListeners.keydown ?? [])].forEach((fn) =>
|
||||
fn({ key: name, target: null, preventDefault: () => (prevented = true) }),
|
||||
);
|
||||
return prevented;
|
||||
};
|
||||
const pointer = (inProfile) =>
|
||||
[...(docListeners.pointerdown ?? [])].forEach((fn) =>
|
||||
fn({ target: Object.assign(new Node("g"), { closest: () => (inProfile ? {} : null) }) }),
|
||||
);
|
||||
|
||||
/** 단면 하나 — 좌 사면 · 노면 · 우 사면(+ 좌 구조물 트림 선). */
|
||||
function card(sectionMode, ditch) {
|
||||
for (const k of Object.keys(docListeners)) if (k === "keydown") docListeners[k] = [];
|
||||
const design = {
|
||||
road_edges: {
|
||||
left: { offset_m: 2, elevation_m: 100 },
|
||||
right: { offset_m: -2, elevation_m: 100 },
|
||||
},
|
||||
ditch: ditch ? { type: "standard", top_width_m: 1 } : null,
|
||||
ditch_side: "left",
|
||||
ditch_enabled: !!ditch,
|
||||
section_mode: sectionMode,
|
||||
};
|
||||
const layer = new Node("g");
|
||||
layer.append(
|
||||
line([
|
||||
[6, sectionMode === "left_cut" ? 103 : 97],
|
||||
[2, 100],
|
||||
]),
|
||||
line([
|
||||
[2, 100],
|
||||
[-2, 100],
|
||||
]),
|
||||
line([
|
||||
[-2, 100],
|
||||
[-6, 97],
|
||||
]),
|
||||
line(
|
||||
[
|
||||
[7, 96],
|
||||
[7, 94],
|
||||
],
|
||||
true,
|
||||
),
|
||||
);
|
||||
const values = { left: 0, right: 0 };
|
||||
const handle = pick.attachDesignLinePick({
|
||||
layer,
|
||||
design,
|
||||
x,
|
||||
toDisplayY: y,
|
||||
onPick: () => undefined,
|
||||
shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) },
|
||||
});
|
||||
const at = (offset, structure = false) =>
|
||||
handle.pieces.findIndex(
|
||||
(p) =>
|
||||
p.structure === structure &&
|
||||
Math.min(p.from.offset, p.to.offset) <= offset &&
|
||||
offset <= Math.max(p.from.offset, p.to.offset),
|
||||
);
|
||||
const slot = (name) => handle.panel.querySelector(`.b06-structure-panel__btn--${name}`);
|
||||
const title = () =>
|
||||
handle.panel.descendants.find((n) => n._cls.has("b06-structure-panel__title")).textContent;
|
||||
const rowHidden = () =>
|
||||
handle.panel.descendants
|
||||
.find((n) => n._cls.has("b06-structure-panel__struct"))
|
||||
._cls.has("is-hidden");
|
||||
return { design, handle, values, at, slot, title, rowHidden };
|
||||
}
|
||||
|
||||
const out = {};
|
||||
|
||||
// ① 성토측(both_fill) — 좌 사면: 가로만 · 바깥만 · 중심 못 넘음 · ▲▼ 막힘
|
||||
{
|
||||
const c = card("both_fill", false);
|
||||
out.axis = {
|
||||
left: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(4)]),
|
||||
road: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(0)]),
|
||||
right: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(-4)]),
|
||||
structure: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(7, true)]),
|
||||
};
|
||||
c.handle.setActive(c.at(4));
|
||||
const upBlocked = press("ArrowUp");
|
||||
const downBlocked = press("ArrowDown");
|
||||
const afterUpDown = { ...c.values };
|
||||
press("ArrowLeft"); // 화면 왼쪽 = 좌 바깥
|
||||
press("ArrowLeft");
|
||||
const out2 = { ...c.values };
|
||||
for (let i = 0; i < 30; i += 1) press("ArrowRight"); // 안쪽 끝까지 — 0 에서 멈춤
|
||||
const inward = { ...c.values };
|
||||
out.fill = {
|
||||
upBlocked,
|
||||
downBlocked,
|
||||
afterUpDown,
|
||||
out2,
|
||||
inward,
|
||||
title: c.title(),
|
||||
rowHidden: c.rowHidden(),
|
||||
};
|
||||
// 종단도를 마지막에 눌렀으면 방향키는 종단도 몫 — 막지 않음
|
||||
pointer(true);
|
||||
out.fill.profileUp = press("ArrowUp");
|
||||
pointer(false);
|
||||
// ② 구조물 트림 선(성토측) = 같은 쪽 노견째 · 반대측 고정
|
||||
c.handle.setActive(c.at(7, true));
|
||||
c.slot("left").click();
|
||||
out.structure = { values: { ...c.values }, title: c.title(), upBlocked: press("ArrowUp") };
|
||||
// 우 사면 = 우 바깥(화면 오른쪽)만 · 좌 값 그대로
|
||||
c.handle.setActive(c.at(-4));
|
||||
press("ArrowLeft"); // 안쪽 — 0
|
||||
press("ArrowRight");
|
||||
out.right = { values: { ...c.values }, title: c.title() };
|
||||
// ④ 도로측 — 높이만: ▲▼ 는 종단도로 넘김(안 막음) · ◀▶ 막힘 · 값 그대로 · 이동 줄 숨김
|
||||
const before = { ...c.values };
|
||||
c.handle.setActive(c.at(0));
|
||||
out.road = {
|
||||
upPassed: !press("ArrowUp"),
|
||||
downPassed: !press("ArrowDown"),
|
||||
leftBlocked: press("ArrowLeft"),
|
||||
rightBlocked: press("ArrowRight"),
|
||||
same: JSON.stringify(before) === JSON.stringify(c.values),
|
||||
title: c.title(),
|
||||
rowHidden: c.rowHidden(),
|
||||
label: c.handle.label.textContent,
|
||||
};
|
||||
// 고름 풀면 아무 키도 안 막음
|
||||
c.handle.setActive(null);
|
||||
out.released = [press("ArrowUp"), press("ArrowLeft")];
|
||||
}
|
||||
|
||||
// ③ 절토측(left_cut · 좌 = 절토 + 측구) — 같은 규칙 반대로
|
||||
{
|
||||
const c = card("left_cut", true);
|
||||
out.cutZone = pick.pieceZone(c.design, c.handle.pieces[c.at(4)]).zone;
|
||||
c.handle.setActive(c.at(4));
|
||||
const upBlocked = press("ArrowUp");
|
||||
c.slot("left").click(); // 좌 바깥
|
||||
const outward = { ...c.values };
|
||||
c.slot("right").click();
|
||||
c.slot("right").click(); // 안쪽 — 0 에서 멈춤
|
||||
out.cut = {
|
||||
upBlocked,
|
||||
outward,
|
||||
inward: { ...c.values },
|
||||
axis: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(4)]),
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,111 @@
|
||||
"""193-1 B06 횡단 보라 계획선 이동 제약 — 갈래(도로 · 절토 · 성토)마다 축 · 방향.
|
||||
|
||||
화면 TS(`_Cross_Design_Pick`)를 흉내 DOM 으로 돌린 값(`helper_193_1_line_axis.cjs`) +
|
||||
서버 재계산(노견 확장 = 그 쪽만 · 반대측 고정).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).parent / "helper_193_1_line_axis.cjs"
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
|
||||
node_only = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
@node_only
|
||||
def test_갈래_판정(out: dict) -> None:
|
||||
assert out["axis"] == {"left": "x", "road": "y", "right": "x", "structure": "x"}
|
||||
assert out["cutZone"] == "cut" and out["cut"]["axis"] == "x"
|
||||
|
||||
|
||||
@node_only
|
||||
def test_성토측_바깥_가로만_중심_못_넘음(out: dict) -> None:
|
||||
fill = out["fill"]
|
||||
assert fill["upBlocked"] and fill["downBlocked"] # ▲▼ 가 종단 계획고로 새지 않음
|
||||
assert fill["afterUpDown"] == {"left": 0, "right": 0}
|
||||
assert fill["out2"] == {"left": 0.2, "right": 0} # ← ← = 좌 바깥 0.2 · 우 그대로
|
||||
assert fill["inward"] == {"left": 0, "right": 0} # 안쪽 30 걸음 = 0 에서 멈춤
|
||||
assert fill["title"] == "사면 이동 (좌)" and fill["rowHidden"] is False
|
||||
assert fill["profileUp"] is False # 종단도를 눌렀으면 방향키는 종단도 몫
|
||||
assert out["right"]["values"] == {"left": 0.1, "right": 0.1} # 우 사면 = 우 바깥만
|
||||
|
||||
|
||||
@node_only
|
||||
def test_구조물_선_반대측_고정_한_벌(out: dict) -> None:
|
||||
"""성토측 구조물 트림 선 = 그 쪽 노견째(벽은 노견 끝에 섬) · 반대측 0 그대로."""
|
||||
assert out["structure"]["values"] == {"left": 0.1, "right": 0}
|
||||
assert out["structure"]["title"] == "사면 이동 (좌)" and out["structure"]["upBlocked"]
|
||||
|
||||
|
||||
@node_only
|
||||
def test_절토측_같은_규칙(out: dict) -> None:
|
||||
cut = out["cut"]
|
||||
assert cut["upBlocked"]
|
||||
assert cut["outward"] == {"left": 0.1, "right": 0}
|
||||
assert cut["inward"] == {"left": 0, "right": 0}
|
||||
|
||||
|
||||
@node_only
|
||||
def test_도로측_높이만_종단_함께(out: dict) -> None:
|
||||
road = out["road"]
|
||||
assert road["upPassed"] and road["downPassed"] # ▲▼ = 종단도 계획고(지금 길 그대로)
|
||||
assert road["leftBlocked"] and road["rightBlocked"] and road["same"]
|
||||
assert road["title"] == "노면 높이" and road["rowHidden"] is True
|
||||
assert "이동" not in road["label"]
|
||||
assert out["released"] == [False, False] # 고름 풀면 막지 않음
|
||||
|
||||
|
||||
def test_종단도는_막힌_방향키를_건너뜀() -> None:
|
||||
src = (ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel_Tools.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "if (event.defaultPrevented) return;" in src
|
||||
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in src
|
||||
|
||||
|
||||
def _ground(level: float) -> list[dict]:
|
||||
return [{"offset_m": o / 2, "elevation_m": level, "valid": True} for o in range(-30, 31)]
|
||||
|
||||
|
||||
def test_서버_한쪽_이동_반대측_고정_바깥으로() -> None:
|
||||
samples = _ground(97.0)
|
||||
base = compute_cross_design(samples, 100.0, ground_type="soil", section_mode="both_fill")
|
||||
moved = compute_cross_design(
|
||||
samples, 100.0, ground_type="soil", section_mode="both_fill", shoulder_extend_left_m=0.7
|
||||
)
|
||||
right = lambda d: [p for p in d["design_line"] if p["offset_m"] < 0] # noqa: E731
|
||||
assert right(moved) == right(base) # 반대측 고정
|
||||
assert moved["road_edges"]["right"] == base["road_edges"]["right"]
|
||||
assert moved["road_edges"]["left"]["offset_m"] == pytest.approx(
|
||||
base["road_edges"]["left"]["offset_m"] + 0.7
|
||||
)
|
||||
toe = lambda d: max(p["offset_m"] for p in d["design_line"] if p["elevation_m"] > 97.0) # noqa: E731
|
||||
assert toe(moved) > toe(base) # 사면째 바깥으로
|
||||
|
||||
|
||||
def test_구조물은_노견_끝에_섬() -> None:
|
||||
"""관 기슭막이 자리 = 설계 노견 끝(`road_edges` · 노견 확장 포함) — 사면과 한 벌로 감."""
|
||||
src = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Geom.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "const edges = section.design?.road_edges;" in src
|
||||
Reference in New Issue
Block a user