- 선(설계 · 구조물 · 원지반 · 사토장)을 만나는 점에서 조각으로 나눔 · 조각마다 판정 선 - 누름 = 하나 · Shift/Ctrl+누름 = 더하기/빼기 · [+ − ⤢] 왼쪽 값 두 줄(길이 합 · 면적 · 닫히지 않음) - 칸 넣기 = 우클릭 고르기 길 그대로 · 길이 한 번 누름 = 바로 · 여럿 = Enter · [칸에 넣기] - 저장 = 조각 두 끝 목록(pieces) · 서버가 설계선 · 원지반선 · 사토장선 위 검산 · 구조물 선은 두 끝 직선 · 옛 두 끝 꼴 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
200 lines
8.5 KiB
Python
200 lines
8.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""PLAN 209-1 — B06 [상세] 그래프 고르기(교차점 분할 · 여러 고름 · 원지반 포함 · 닫힌 넓이).
|
|
|
|
정보창(고른 것 길이 · 면적) · 칸 넣기 · 서버 검산 · 옛 꼴 읽기.
|
|
|
|
못박는 것
|
|
- 선(설계선 · 원지반선)이 만나는 점에서 조각으로 나뉨 · 화면 px → m · 격자 등은 안 고름.
|
|
- 여러 고름(Shift/Ctrl 더하기 · 빼기) · 원지반 조각 포함 닫힌 영역 넓이 · 안 닫히면 null.
|
|
- 정보창 두 줄(하나 = 카드 꼴 길이 · 경사비율 / 여럿 = 길이 합 · 면적 · 「닫히지 않음」).
|
|
- 칸 넣기 출처(조각 두 끝 목록) → 스키마 → 서버 `cell_value` 거울(① 파이썬 · TS) ·
|
|
서버 검산(선 밖 · 설계선 바뀜 → 다시 고름 · 구조물 선 = 두 끝 직선) · 옛 두 끝 꼴 그대로.
|
|
|
|
./venv/Scripts/python.exe -m pytest resources/tester/test_209_1_detail_select.py -q
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from math import hypot
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from B06_Section import B06_Section_Router_CrossTable as table_mod
|
|
from B06_Section.B06_Section_Schema import CrossSectionPatch
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import (
|
|
cell_value,
|
|
ground_runs,
|
|
ring_area,
|
|
sub_path,
|
|
)
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
HERE = Path(__file__).resolve().parent
|
|
|
|
LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)]
|
|
DESIGN = {
|
|
"design_elevation_m": 98.0,
|
|
"road_edges": {
|
|
"left": {"offset_m": 2.0, "elevation_m": 98.0},
|
|
"right": {"offset_m": -2.0, "elevation_m": 98.0},
|
|
},
|
|
"design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE],
|
|
}
|
|
SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] + [
|
|
{"offset_m": 12.0, "elevation_m": None, "valid": False},
|
|
{"offset_m": 14.0, "elevation_m": 101.0, "valid": True},
|
|
]
|
|
AXES = {
|
|
"widthPx": 600,
|
|
"heightPx": 300,
|
|
"pixelsPerMeter": 10,
|
|
"maxOffset": 14,
|
|
"displayMax": 110,
|
|
"elevationMid": 100,
|
|
"exaggeration": 2,
|
|
}
|
|
CROSS_X = 2 + 4 / 3 # 경사(1:0.667)가 원지반 100 m 와 만나는 offset
|
|
AREA = (2 * CROSS_X + 4) / 2 * 2 # 사다리꼴(윗변 2·CROSS_X · 아랫변 4 · 높이 2)
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def out() -> dict:
|
|
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
|
["node", str(HERE / "helper_209_1_detail_select.cjs")],
|
|
input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}),
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
timeout=60,
|
|
cwd=os.fspath(ROOT),
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
def _ends(piece: dict) -> list[tuple[float, float]]:
|
|
pts = piece["points"]
|
|
return [tuple(round(v, 4) for v in pts[0]), tuple(round(v, 4) for v in pts[-1])]
|
|
|
|
|
|
# ── 분할 · 여러 고름 ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_교차점에서_조각으로_나뉨(out):
|
|
pieces = out["pieces"]
|
|
ground = [p for p in pieces if p["line"] == "ground"]
|
|
design = [p for p in pieces if p["line"] == "design"]
|
|
assert len(ground) == 3 and len(design) == 5 # 원지반 = 두 교점에서 셋 · 설계 경사 둘이 둘씩
|
|
c = round(CROSS_X, 4)
|
|
assert sorted(_ends(p)[0][0] for p in ground) == pytest.approx([-10.0, -c, c])
|
|
# 원지반 가운데 조각은 사이 샘플 꼭짓점(0 m)을 지남 — 꺾은선 그대로
|
|
middle = next(p for p in ground if abs(p["points"][0][0]) < 4)
|
|
assert [0.0, 100.0] in [[round(v, 6) for v in q] for q in middle["points"]]
|
|
# 경사 조각 끝 = 교점(설계선 위 · 원지반 위)
|
|
assert any(_ends(p)[1] == (c, 100.0) or _ends(p)[0] == (c, 100.0) for p in design)
|
|
|
|
|
|
def test_여러_고름_더하기_빼기(out):
|
|
toggle = out["toggle"]
|
|
assert toggle == {"single": [3], "again": [], "add": [1, 3], "remove": [1]}
|
|
|
|
|
|
# ── 닫힌 넓이 · 정보창 ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_원지반_포함_닫힌_영역_넓이(out):
|
|
assert out["ringCount"] == 4 # 경사 둘 · 바닥 · 원지반 가운데
|
|
assert out["area"] == pytest.approx(AREA)
|
|
assert out["open"] is None # 원지반 빼면 안 닫힘
|
|
|
|
|
|
def test_정보창_값(out):
|
|
label = out["label"]
|
|
length = 2 * hypot(4 / 3, 2) + 4 + 2 * CROSS_X
|
|
assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡"
|
|
assert label["open"].endswith("면적 닫히지 않음")
|
|
assert label["single"] == "길이 4.00m · 경사비율 0.0%\n수평 4.00m · 높이 0.00m" # 카드 꼴
|
|
assert label["ground"] == (
|
|
f"원지반선 길이 {2 * CROSS_X:.2f}m\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m"
|
|
)
|
|
|
|
|
|
# ── 칸 넣기 · 짝 · 서버 검산 ─────────────────────────────────────────────
|
|
|
|
|
|
def test_칸_넣기_출처_저장_꼴(out):
|
|
area = out["sourceArea"]
|
|
assert area["src"] == "area" and len(area["pieces"]) == 4
|
|
assert {p["line"] for p in area["pieces"]} == {"design", "ground"}
|
|
assert out["sourceOpen"] is None # 안 닫힌 면적은 칸에 못 넣음
|
|
assert out["sourceLength"]["src"] == "length"
|
|
patch = CrossSectionPatch(
|
|
chainage_m=40.0,
|
|
detail_table={"cells": {"cut_soil": area, "fence": out["sourceLength"]}},
|
|
).model_dump()
|
|
assert patch["detail_table"]["cells"]["cut_soil"]["pieces"][0]["line"] in ("design", "ground")
|
|
|
|
|
|
def test_칸_값_파이썬_TS_거울(out):
|
|
for source, local in zip(out["sources"], out["local"], strict=True):
|
|
number, _text, again = cell_value(source, DESIGN, SAMPLES)
|
|
if number is None:
|
|
assert local is None and again is True, source
|
|
else:
|
|
assert local == pytest.approx(number, abs=1e-6), source
|
|
assert out["local"][0] == pytest.approx(AREA, abs=1e-3)
|
|
assert out["local"][1] == pytest.approx(out["lengthSum"], abs=1e-3)
|
|
assert out["local"][2] == pytest.approx(out["lengthSum"] + 5.0, abs=1e-3) # 구조물 = 두 끝 직선
|
|
assert out["local"][3] is None # 원지반 밖 끝
|
|
|
|
|
|
def test_서버_검산_선_밖이나_설계선_바뀌면_다시_고름(out):
|
|
cells = {"cut_soil": out["sourceArea"], "fence": out["sourceLength"]}
|
|
stored = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": cells}).model_dump()
|
|
design = {**DESIGN, "detail_table": stored["detail_table"]}
|
|
source = {"chainage_m": 40.0, "center_z": 100.0, "samples": SAMPLES}
|
|
sheet = table_mod.cross_quantity_sheet(source, design)
|
|
assert sheet["quantity_table"]["cut_soil"] == pytest.approx(AREA, abs=1e-3)
|
|
assert sheet["quantity_table"]["fence"] == pytest.approx(out["lengthSum"], abs=1e-3)
|
|
assert sheet["repick"] == []
|
|
# 원지반 샘플이 없으면(검산 못 함) 다시 고름 — 브라우저 값 안 받음
|
|
blind = table_mod.cross_quantity_sheet({"chainage_m": 40.0}, design)
|
|
assert sorted(blind["repick"]) == ["cut_soil", "fence"]
|
|
# 설계선이 내려가면 조각 끝이 선 밖 → 다시 고름
|
|
moved = {**design, "design_line": [{"offset_m": x, "elevation_m": y - 1} for x, y in LINE]}
|
|
again = table_mod.cross_quantity_sheet(source, moved)
|
|
assert again["quantity_table"]["cut_soil"] is None
|
|
assert sorted(again["repick"]) == ["cut_soil", "fence"]
|
|
|
|
|
|
def test_옛_두_끝_꼴_그대로_읽음():
|
|
old = {"src": "length", "start": [2.0, 98.0], "end": [6.0, 104.0]}
|
|
assert cell_value(old, DESIGN, SAMPLES)[0] == pytest.approx(hypot(4.0, 6.0))
|
|
assert cell_value({"src": "area", "key": "cut_soil"}, {"cut_soil_area_m2": 1.5}) == (
|
|
1.5,
|
|
None,
|
|
False,
|
|
)
|
|
|
|
|
|
def test_서버_조각_길_넓이_기본():
|
|
assert ground_runs(SAMPLES) == [
|
|
[(-10, 100.0), (-5, 100.0), (0, 100.0), (5, 100.0), (10, 100.0)]
|
|
]
|
|
run = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0)]
|
|
assert sub_path(run, 3.0, 1.0) == [(2.0, 1.0), (2.0, 0.0), (1.0, 0.0)]
|
|
square = [
|
|
[(0.0, 0.0), (1.0, 0.0)],
|
|
[(1.0, 1.0), (1.0, 0.0)],
|
|
[(1.0, 1.0), (0.0, 1.0), (0.0, 0.0)],
|
|
]
|
|
assert ring_area(square) == pytest.approx(1.0)
|
|
assert ring_area(square[:2]) is None
|
|
assert ring_area([*square, [(5.0, 5.0), (6.0, 6.0)]]) is None # 남는 조각
|