feat(B07): 종단도 장마다 우측 상단 평면 위치도 · 종단 ⚙ 「평면도」 척도 칸 (521-2 · 요구 340-2)

- 새 Engine_Cad_LongPlan — 작도 영역 우상단 5 mm 안쪽 150×100 mm 칸 · 자동 = 전체 노선 맞춤(분모 500 올림) · 척도 정함 = 이 장 중심 확대 · 밖 노선 흐린 파선 · 칸 밖 잘림 · 이 장 굵게 · 시 · 종점 측점 · 방위표 · 「평면도 S=1/n」 · 「n/N 장」 · 좌표 없으면 안 그림
- 장 나눔 — 그래프가 칸을 덮으면 측점 줄여 장 더 나눔 · 장 목록에 number · total
- 축척 — longitudinal.plan 기본 자동 · 범위 1/500~1/50,000 · ⚙ 칸 = 서버 기본값 열쇠 그대로(셋째 칸 + 자동)
- 칸 앞 확정 종단 저장본 — 목록 받을 때 종단만 한 번 모두 풂(DRAWING_FORMAT 그대로)
- 시험 — 칸 자리 · 안 겹침 · 장마다 강조 · 척도 확대 · 좌표 없음 · 한 번 풂 · plan 범위 · ⚙ 평면 칸

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 08:01:02 +09:00
co-authored by Claude Opus 5.5
parent b522a52622
commit df0e64ffe2
12 changed files with 472 additions and 37 deletions
+15 -3
View File
@@ -97,19 +97,19 @@ const calls = [];
let failConfirm = false;
const SCALES = {
scales: {
longitudinal: { x: 1000, y: 200 },
longitudinal: { x: 1000, y: 200, plan: null },
cross: { x: 100, y: 100 },
mass_haul: { x: null, y: 50000 },
development: { x: null, y: null },
},
defaults: {
longitudinal: { x: 1000, y: 200 },
longitudinal: { x: 1000, y: 200, plan: null },
cross: { x: 100, y: 100 },
mass_haul: { x: null, y: 50000 },
development: { x: null, y: null },
},
ranges: {
longitudinal: { x: [100, 10000], y: [10, 2000] },
longitudinal: { x: [100, 10000], y: [10, 2000], plan: [500, 50000] },
cross: { x: [10, 1000], y: [10, 1000] },
mass_haul: { x: [100, 20000], y: [1000, 1000000] },
development: { x: [10, 10000], y: [10, 10000] },
@@ -413,6 +413,18 @@ const drawings = [
s.btn("축척 저장").click();
await tick();
scale.same = { calls: calls.length, toasts: toasts.slice(toastAt).map((t) => t[1]) };
// 평면 위치도 칸(521-2) — 셋째 칸 · 자동 끔 → 범위 밖 붉음 → 1/3,000 저장
s = await scaleOf("longitudinal");
scale.planStart = s.state();
s.toggle(s.autos[0]);
s.type(s.inputs[2], "400");
scale.planLow = s.state();
s.type(s.inputs[2], "3000");
calls.length = 0;
s.btn("축척 저장").click();
await tick();
await tick();
scale.planSent = calls.find((c) => c[0] === "scales")?.[1];
s = await scaleOf("mass_haul");
scale.massStart = s.state();
@@ -244,7 +244,14 @@ def test_전개도_가로_세로(project: Path) -> None:
def test_범위_검사() -> None:
assert checked_scales({"cross": {"x": 100, "y": None}}) == {"cross": {"x": 100, "y": None}}
# 종단 평면 위치도(521-2) = 1/500~1/50,000 · null = 자동
assert checked_scales({"longitudinal": {"plan": 3000}}) == {
"longitudinal": {"x": None, "y": None, "plan": 3000}
}
with pytest.raises(ValueError, match="평면도 축척은 1/500~1/50,000"):
checked_scales({"longitudinal": {"plan": 400}})
for bad in (
{"longitudinal": {"plan": 50001}},
{"plan": {"x": 500}},
{"standard": {"x": 50}},
{"cross": {"x": 5}},
@@ -287,7 +294,7 @@ def test_바꾼_종류만_저장본_풂(project: Path) -> None:
drawings = _read_manifest(project)["drawings"]
assert not drawings["longitudinal_1"]["confirmed"]
assert drawings["cross_00020m"]["confirmed"] and drawings["cover"]["confirmed"]
assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200}
assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200, "plan": None}
# 보낸 종류만 바뀜 — 앞서 쓴 종단은 남음 · 횡단 = 측점 설계 잠정 몫
invalidated, stale = write_drawing_scales(
project, project / "none.json", {"cross": {"x": 100, "y": 50}}
@@ -314,5 +321,5 @@ def test_GET_PUT_범위_밖_400(project: Path, monkeypatch: pytest.MonkeyPatch)
assert not drawing_scales_path(project).exists() # 거절 = 안 씀
put = client.put(url, json={"scales": {"longitudinal": {"x": 500, "y": 200}}})
assert put.status_code == 200
assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200}
assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200, "plan": None}
assert put.json()["invalidated"] == {}
+12 -7
View File
@@ -229,22 +229,27 @@ def test_485_2_축척_칸_판정(out):
def test_485_2_종단_축척_저장(out):
scale = out["scale"]
assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/"]
assert scale["longStart"]["values"] == ["1000", "200"]
assert scale["longStart"]["autos"] == [] # 종단 = 자동 없음
assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/", "평면도 1/"]
assert scale["longStart"]["values"] == ["1000", "200", ""]
assert scale["longStart"]["autos"] == [True] # 종단 = 평면도 칸만 자동(521-2)
for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + 저장 꺼짐
assert scale[bad]["errors"] == [RANGE, None]
assert scale[bad]["errors"] == [RANGE, None, None]
assert scale[bad]["save"] is False
assert scale["longOk"]["errors"] == [None, None] and scale["longOk"]["save"] is True
assert scale["longOk"]["errors"] == [None, None, None] and scale["longOk"]["save"] is True
# 저장본 2장 — 물음 [취소] = 요청 없음 · 모달 그대로
assert scale["cancel"] == {"calls": ["ask"], "open": True, "closed": 0}
save = scale["save"]
assert "저장본 2장" in save["ask"]
assert save["sent"] == {"longitudinal": {"x": 500, "y": None}} # Y = 기본 → null
assert save["sent"] == {"longitudinal": {"x": 500, "y": None, "plan": None}} # 기본 → null
assert save["open"] is False
assert save["closed"] == [[True, "longitudinal"]] # 화면 다시 세움 · 같은 도면
assert scale["reset"]["values"] == ["1000", "200"] # [기본값] = 칸만
assert scale["reset"]["values"] == ["1000", "200", ""] # [기본값] = 칸만
assert scale["same"] == {"calls": 0, "toasts": ["바뀐 축척이 없습니다."]}
# 평면 위치도 칸(521-2) — 처음 자동(칸 꺼짐) · 1/500 밑 = 붉음 · 1/3,000 저장
assert scale["planStart"]["disabled"] == [False, False, True]
assert scale["planLow"]["errors"] == [None, None, "1/500~1/50,000 사이 정수"]
assert scale["planLow"]["save"] is False
assert scale["planSent"] == {"longitudinal": {"x": None, "y": None, "plan": 3000}}
def test_485_2_토적_전개도_자동(out):
+142 -2
View File
@@ -1,4 +1,10 @@
"""B07 종단도 — 장마다 그림 좌하단 = 작도영역 좌하단 · 고정 축척 · 넘치면 장을 더 나눔 (59-3)."""
"""B07 종단도 — 장마다 그림 좌하단 = 작도영역 좌하단 · 고정 축척 · 넘치면 장을 더 나눔 (59-3).
우측 상단 평면 위치도(521-2) — 칸 자리 · 그래프와 안 겹침 · 장마다 강조 구간 · 척도.
"""
import json
import math
import pytest
@@ -7,7 +13,21 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
build_longitudinal_drawing,
longitudinal_chunks,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, usable_area
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_LongPlan import (
LONG_PLAN_LAYER_ID,
plan_box_overlaps,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
drawing_scales_path,
entities_bbox,
usable_area,
use_project_templates,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_read_manifest,
_write_manifest,
release_old_long_sheets,
)
def _longitudinal(count: int, interval: float, slope: float) -> dict:
@@ -57,3 +77,123 @@ def test_wide_interval_splits_more() -> None:
# 40 m 간격 30측점 = 1,160 mm > 작도영역 폭 → 30개보다 적게 담는다
chunks = longitudinal_chunks(_longitudinal(40, 40.0, 0.0))
assert len(chunks) > 2
# ── 우측 상단 평면 위치도(521-2 · 요구 340-2) ─────────────────────────────────
def _with_xy(longitudinal: dict) -> dict:
for station in longitudinal["stations"]:
chainage = station["chainage_m"]
station["center_x"] = 200000.0 + chainage
station["center_y"] = 500000.0 + 60.0 * math.sin(chainage / 150.0)
return longitudinal
def _plan_sheets(longitudinal: dict) -> list[dict]:
"""장마다 그래프 bbox · 칸 엔티티 · 굵은(이 장) 선 꼭짓점 · 글."""
sheets = []
for chunk in longitudinal_chunks(longitudinal):
entities = build_longitudinal_drawing(longitudinal, chunk["id"], chunk)["entities"]
graph = [e for e in entities if e["layerId"] not in (FRAME_LAYER_ID, LONG_PLAN_LAYER_ID)]
plan = [e for e in entities if e["layerId"] == LONG_PLAN_LAYER_ID]
bold = [
(child["shapeData"][key]["x"], child["shapeData"][key]["y"])
for entity in plan
for child in entity.get("children") or []
if child["lineWidth"] == 3
for key in ("startPoint", "endPoint")
]
texts = [e["shapeData"]["label"] for e in plan if e["type"] == "Text"]
sheets.append(
{
"graph": graph,
"bbox": entities_bbox(graph),
"plan": plan,
"bold": bold,
"texts": texts,
}
)
return sheets
def test_평면_칸_작도영역_우상단_안_겹침_장마다_강조() -> None:
longitudinal = _with_xy(_longitudinal(66, 20.0, 0.05))
sheets = _plan_sheets(longitudinal)
usable_w, usable_h = usable_area()
lows = []
for number, sheet in enumerate(sheets, start=1):
x0, y0 = sheet["bbox"][0], sheet["bbox"][1]
box = entities_bbox(sheet["plan"])
# 칸 = 작도 영역 우상단 5 mm 안쪽 150×100 mm
assert box == pytest.approx(
(x0 + usable_w - 155.0, y0 + usable_h - 105.0, x0 + usable_w - 5.0, y0 + usable_h - 5.0)
)
assert not plan_box_overlaps(longitudinal, sheet["graph"], sheet["bbox"])
assert f"{number}/{len(sheets)} 장" in sheet["texts"]
assert any(text.startswith("평면도 S=1/") for text in sheet["texts"])
assert sheet["bold"] # 이 장 구간 굵게
lows.append(min(x for x, _y in sheet["bold"]) - box[0])
# 자동 = 모든 장이 같은 축척 · 같은 자리 — 굵은 구간만 장마다 오른쪽으로 옮겨 감
assert lows == sorted(lows) and len(set(lows)) == len(lows)
def test_칸을_덮으면_측점_줄여_장_더_나눔() -> None:
# 25 m 간격 30 측점 = 725 mm(폭 안) · 오르막 그래프가 우상단 칸을 덮음
plain = longitudinal_chunks(_longitudinal(60, 25.0, 0.1))
with_box = _with_xy(_longitudinal(60, 25.0, 0.1))
assert longitudinal_chunks(with_box)[0]["end_m"] < plain[0]["end_m"]
for sheet in _plan_sheets(with_box):
assert not plan_box_overlaps(with_box, sheet["graph"], sheet["bbox"])
def test_좌표_없으면_칸_없음() -> None:
assert all(not sheet["plan"] for sheet in _plan_sheets(_longitudinal(40, 20.0, 0.0)))
def test_척도_정하면_이_장_확대_밖_흐린_파선(tmp_path) -> None:
longitudinal = _with_xy(_longitudinal(66, 20.0, 0.05))
use_project_templates(tmp_path)
try:
path = drawing_scales_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"longitudinal": {"plan": 3000}}), encoding="utf-8")
sheets = _plan_sheets(longitudinal)
finally:
use_project_templates(None)
for sheet in sheets:
assert "평면도 S=1/3,000" in sheet["texts"]
assert any(entity.get("lineDash") for entity in sheet["plan"]) # 밖 노선 파선
box = entities_bbox(sheet["plan"])
xs = [x for x, _y in sheet["bold"]]
# 이 장 중심 = 칸 노선 자리 가운데
assert (min(xs) + max(xs)) / 2.0 == pytest.approx((box[0] + box[2]) / 2.0, abs=1.0)
def test_칸_앞_확정_종단만_한_번_풂(tmp_path) -> None:
drawings = tmp_path / "B07_DesignDetail" / "drawings"
drawings.mkdir(parents=True)
old = {"kind": "longitudinal", "confirmed": True, "file": "x"}
_write_manifest(
tmp_path,
{
"drawings": {
"longitudinal_1": dict(old),
"longitudinal_2": dict(old),
"cross_00020m": {"kind": "cross", "confirmed": True, "file": "x"},
}
},
)
for name in ("longitudinal_1", "longitudinal_2", "cross_00020m"):
(drawings / f"{name}.json").write_text('{"layers": []}', encoding="utf-8")
release_old_long_sheets(tmp_path)
after = _read_manifest(tmp_path)["drawings"]
assert not after["longitudinal_1"]["confirmed"] and not after["longitudinal_2"]["confirmed"]
assert after["cross_00020m"]["confirmed"]
assert not (drawings / "longitudinal_1.json").exists() # 옛 저장본 걷힘
# 새 확정본(칸 층 있음)은 그대로
after["longitudinal_1"] = dict(old)
_write_manifest(tmp_path, {"drawings": after})
(drawings / "longitudinal_1.json").write_text(
json.dumps({"layers": [{"id": LONG_PLAN_LAYER_ID}]}), encoding="utf-8"
)
release_old_long_sheets(tmp_path)
assert _read_manifest(tmp_path)["drawings"]["longitudinal_1"]["confirmed"]