Merge remote-tracking branch 'origin/sub_laptop_8' into sub_desktop_1

This commit is contained in:
2026-10-09 15:22:18 +09:00
15 changed files with 1121 additions and 238 deletions
+22 -2
View File
@@ -38,7 +38,9 @@ _STATIONS = [
def test_scale_choices_add_update_and_old_files(tmp_path: Path) -> None:
root = tmp_path / "project"
state = _state(root)
assert state["scales"] == [600, 1000, 1200] and state["default_scale"] == 1200
# 141-2 — 지식DB 근거 있는 여덟
assert state["scales"] == [500, 600, 1000, 1200, 2500, 3000, 5000, 6000]
assert state["default_scale"] == 1200
assert all(k["scale"] == (6000 if k["layout"] == "watershed" else 1200) for k in state["kinds"])
added = add_kind(root, "용지 600", ["contour"], "plan", 600)
assert added["scale"] == 600
@@ -49,7 +51,7 @@ def test_scale_choices_add_update_and_old_files(tmp_path: Path) -> None:
update_kind(root, added["id"], "용지 1000", ["contour"]) # 축척 없이 = 지금 값
assert next(k for k in list_kinds(root) if k["id"] == added["id"])["scale"] == 1000
with pytest.raises(ValueError):
add_kind(root, "이상한 축척", ["contour"], "plan", 500)
add_kind(root, "이상한 축척", ["contour"], "plan", 700)
with pytest.raises(ValueError):
update_kind(root, added["id"], "용지 1000", ["contour"], 777)
watershed = add_kind(root, "유역 둘", [], "watershed", 600) # 유역도 양식은 고정
@@ -171,3 +173,21 @@ def test_ip_table_clip_and_parcel_label_follow_scale() -> None:
(half,) = label_rects(items, 1000.0 / 600)
assert half[2] - half[0] == pytest.approx((big[2] - big[0]) / 2)
assert half[3] - half[1] == pytest.approx((big[3] - big[1]) / 2)
def test_실거리_상수는_종이_거리가_같게_141_2() -> None:
"""비탈 눈금 · 대피소 띄움 · 필지 버퍼 = 1/1,200 실거리 × 축척 비 — 1/6,000 눈금 간격 10 m."""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import slope_pieces, tick_step_m
assert tick_step_m(1200) == 2.0 and tick_step_m(6000) == 10.0 and tick_step_m(500) < 1.0
route = [(0.0, 0.0, 0.0), (200.0, 200.0, 0.0)]
records = [
{"chainage_m": c, "left_edge_m": 2.0, "left_end_m": 6.0, "right_edge_m": -2.0,
"right_end_m": -6.0}
for c in (0.0, 200.0)
] # fmt: skip
for scale in (1200, 6000):
ticks = slope_pieces(route, records, 0.0, 200.0, tick_step_m(scale))[3]["ticks"]
left = sorted(t[0][0] for t in ticks if t[0][1] > 0)
gaps_mm = [(b - a) * 1000.0 / scale for a, b in zip(left, left[1:])]
assert min(gaps_mm) == pytest.approx(2000.0 / 1200.0) # 1.67 mm — 검은 띠 없음
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""141-3 평면도 조각 「배수유역 경계」 · 「유역 번호」 — 세부유역을 장 범위로 잘라 유역도 해치 · 배지로.
견본: 유역 1 = 장 안 정사각 · 유역 2 = 장 경계에 걸침(잘림) · 유역 3 = 장 밖(빠짐).
"""
from __future__ import annotations
from types import SimpleNamespace
from B07_DesignDetail import B07_DesignDetail_Router_Support_Basin as basin
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
PIECE_IDS,
build_plan_kind_drawing,
layer_id,
)
from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import PIECES
BOX = (0.0, 0.0, 100.0, 100.0)
BASINS = [
{"ring": [(10, 10), (40, 10), (40, 40), (10, 40), (10, 10)], "props": {"index": 1}},
{"ring": [(80, 50), (120, 50), (120, 90), (80, 90), (80, 50)], "props": {"index": 2}},
{"ring": [(300, 300), (320, 300), (320, 320), (300, 300)], "props": {"index": 3}},
]
def _pieces(monkeypatch) -> dict:
monkeypatch.setattr(basin, "metric_basins", lambda _context: BASINS)
return basin.plan_basin_pieces(SimpleNamespace(), BOX)
def test_장_범위로_자르고_번호는_유역도와_같다(monkeypatch):
pieces = _pieces(monkeypatch)
rings = pieces["basin"]["items"]
assert [item["number"] for item in rings] == [1, 2] # 장 밖 3 은 빠짐
clipped = rings[1]["ring"]
assert max(x for x, _y in clipped) == 100.0 # 장 경계에서 잘림
numbers = {item["number"]: item["center"] for item in pieces["basin_no"]["items"]}
assert set(numbers) == {1, 2}
x, y = numbers[2]
assert 80 <= x <= 100 and 50 <= y <= 90 # 잘린 조각 안
def test_유역_저장본_없으면_빈_조각(monkeypatch):
def missing(_context):
raise FileNotFoundError
monkeypatch.setattr(basin, "metric_basins", missing)
assert basin.plan_basin_pieces(SimpleNamespace(), BOX) == {
"basin": {"items": []},
"basin_no": {"items": []},
}
def test_어느_평면도에나_조각_체크(monkeypatch):
assert {"basin", "basin_no"} <= set(PIECE_IDS)
assert ("basin", "배수유역 경계", False) in PIECES and (
"basin_no",
"유역 번호",
False,
) in PIECES
drawing = build_plan_kind_drawing("plan_x", "평면도(x)", BOX, _pieces(monkeypatch), 1200)
hatches = [e for e in drawing["entities"] if e["layerId"] == layer_id("basin")]
assert [e["type"] for e in hatches].count("Hatch") == 2
badges = [e for e in drawing["entities"] if e["layerId"] == layer_id("basin_no")]
assert sorted(e["shapeData"]["label"] for e in badges if e["type"] == "Text") == ["1", "2"]
assert len({e["id"] for e in drawing["entities"]}) == len(drawing["entities"])
names = {layer["id"]: layer["name"] for layer in drawing["layers"]}
assert names[layer_id("basin")] == "배수유역 경계"
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""141-4 평면도 여러 장 — 노선 따라 자르기 유지 + 맞춤선 · 이웃 구간 흐리게 · 「n/N장」 · 돌린 맞춤 뺌."""
from __future__ import annotations
import math
from pathlib import Path
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
_chunk_span_m,
plan_chunks,
sheet_links,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
SHEET_LAYER,
build_plan_kind_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import plan_kind_items
# 남북으로 곧은 노선 1,200 m · 측점 20 m
STATIONS = [(float(c), 0.0, float(c)) for c in range(0, 1201, 20)]
def test_돌린_맞춤_빼기_남북_노선은_그림칸_높이로_나눔():
span_w, span_h = _chunk_span_m(1200)
assert span_h < 800 < span_w # 옛 규칙 = 돌려 보면 한 장(그림칸 높이를 넘침)
chunks = plan_chunks(STATIONS[:41], 1200) # 800 m
assert len(chunks) == 2
for chunk in chunks:
assert chunk["end_m"] - chunk["start_m"] <= span_h
def test_맞춤선_경계_측점_직각_이웃_장_번호():
chunks = plan_chunks(STATIONS, 1200)
total = len(chunks)
assert total == 3
middle = chunks[1]
box = (-300.0, middle["start_m"] - 10.0, 300.0, middle["end_m"] + 10.0)
mm = 1000.0 / 1200
links = sheet_links(STATIONS, middle, total, box, 20.0, mm, neighbors=True)
first, last = links["lines"]
start_no, end_no = int(middle["start_m"] // 20), int(middle["end_m"] // 20)
assert first["label"] == f"MATCH LINE NO.{start_no} · 1장 이어짐"
assert last["label"] == f"MATCH LINE NO.{end_no} · 3장 이어짐"
(ax, ay), (bx, by) = first["points"]
assert ay == by == middle["start_m"] # 남북 노선에 직각 = 동서
assert math.hypot(bx - ax, by - ay) * mm == 60.0 # 종이 60 mm
# 이웃 구간 노선 = 도곽 안만(앞 · 뒤 10 m)
assert len(links["neighbors"]) == 2
for line in links["neighbors"]:
assert all(box[1] <= y <= box[3] for _x, y in line)
# 첫 장 · 끝 장은 한쪽만 · 중심선 안 고르면 이웃 노선 없음
only = sheet_links(STATIONS, chunks[0], total, box, 20.0, mm, neighbors=False)
assert [item["label"].endswith("· 2장 이어짐") for item in only["lines"]] == [True]
assert only["neighbors"] == []
def test_도면에_맞춤선_층_글은_다른_글과_안_겹침():
chunks = plan_chunks(STATIONS, 1200)
middle = chunks[1]
box = (-300.0, middle["start_m"] - 10.0, 300.0, middle["end_m"] + 10.0)
mm = 1000.0 / 1200
links = sheet_links(STATIONS, middle, 3, box, 20.0, mm, neighbors=True)
at = middle["start_m"]
pieces = {
"sheet_links": links,
# 맞춤선 글 자리에 시종점 글 후보가 처음 놓이는 견본
"endpoints": {"items": [{"label": "시점 No.0", "x": 0.0, "y": at, "nx": 1.0, "ny": 0.0}]},
}
drawing = build_plan_kind_drawing("plan_x_2", "평면도(x) 2/3장", box, pieces, 1200)
sheet = [e for e in drawing["entities"] if e["layerId"] == SHEET_LAYER]
labels = sorted(e["shapeData"]["label"] for e in sheet if e["type"] == "Text")
assert labels == [line["label"] for line in links["lines"]]
assert any(layer["id"] == SHEET_LAYER for layer in drawing["layers"])
assert sum(e["type"] == "PolyLine" for e in sheet) == 4 # 맞춤선 둘 + 이웃 노선 둘
def test_목록_도면명_n_N장(tmp_path: Path):
stations = [{"chainage_m": c, "center_x": x, "center_y": y} for c, x, y in STATIONS]
items = [
i for i in plan_kind_items(tmp_path, {"stations": stations}) if i["plan_kind"] == "terrain"
]
assert [item["label"] for item in items] == [f"평면도(지형) {n}/3장" for n in (1, 2, 3)]
@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
"""141-1 B07 평면도 글자 겹침 피하기.
못박는 것
- 우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소 — 앞 글이 첫 후보(옛 자리)를 가짐.
- 후보 중 첫 빈 자리 · 모두 막히면 첫 후보 그대로(글은 남음).
- 장애물(IP 표 · 필지 상자) 비켜 감.
- 가까운 IP · R · 배수 둘 · NO.0 + 시점 한 장 → 글 사각 겹침 0 · 글 모두 남음.
- 멀리 놓인 글(후보 far)엔 지시선.
"""
from itertools import combinations
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import (
overlaps,
place_labels,
text_rect,
)
def _label(piece: str, label: str, spots: list[dict], anchor=None) -> dict:
return {
"piece": piece,
"seed": f"s:{piece}:{label}",
"label": label,
"size": 2.5,
"layer": "l",
"color": "#fff",
"anchor": anchor,
"candidates": spots,
}
def test_priority_keeps_first_spot_and_next_moves() -> None:
spot = [{"x": 0.0, "y": 0.0}, {"x": 0.0, "y": 10.0}]
placed = place_labels([_label("refuge", "대피소", spot), _label("endpoints", "시점", spot)], [])
by = {item["piece"]: item["spot"] for item in placed}
assert by["endpoints"] == spot[0] # 시종점이 먼저 — 옛 자리
assert by["refuge"] == spot[1]
def test_all_blocked_falls_back_to_first() -> None:
spot = [{"x": 0.0, "y": 0.0}]
placed = place_labels([_label("ip", "IP1", spot), _label("radius", "R=12", spot)], [])
assert [item["spot"] for item in placed] == [spot[0], spot[0]] # 글은 남음
def test_obstacle_is_avoided() -> None:
spots = [{"x": 0.0, "y": 0.0}, {"x": 30.0, "y": 0.0}]
placed = place_labels([_label("ip", "IP1", spots)], [(-5.0, -5.0, 5.0, 5.0)])
assert placed[0]["spot"] == spots[1]
def _texts(drawing: dict) -> list[dict]:
return [
e
for e in drawing["entities"]
if e["type"] == "Text"
and e["layerId"].startswith("b07-plan-")
and e["layerId"] != "b07-plan-title"
]
def _rect(entity: dict) -> tuple:
shape = entity["shapeData"]
opts = shape["options"]
direction = (opts["textDirection"]["x"], opts["textDirection"]["y"])
return text_rect(
shape["label"],
shape["basePoint"]["x"],
shape["basePoint"]["y"],
opts["fontSize"],
opts["textAlign"],
direction,
)
def test_crowded_sheet_has_no_overlapping_labels() -> None:
box = (0.0, 0.0, 300.0, 200.0)
pieces = {
"ip": {
"items": [
{
"name": "IP1",
"apex": [100.0, 100.0],
"start": [90.0, 100.0],
"end": [100.0, 110.0],
},
{
"name": "IP2",
"apex": [103.0, 101.0],
"start": [95.0, 101.0],
"end": [103.0, 111.0],
},
]
},
"radius": {
"items": [
{"label": "R=12", "middle": [100.0, 98.0], "dir": [0.0, -1.0]},
{"label": "R=15", "middle": [103.0, 99.0], "dir": [0.0, -1.0]},
]
},
"drainage": {
"items": [
{"x": 101.0, "y": 96.0, "label": "D800 L=8.0"},
{"x": 102.0, "y": 95.0, "label": "D600 L=6.0"},
]
},
"station": {
"items": [
{"x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0, "label": "NO.0", "major": True},
]
},
"endpoints": {
"items": [{"label": "시점 NO.0", "x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0}]
},
}
drawing = build_plan_kind_drawing("plan_x", "시험", box, pieces)
texts = _texts(drawing)
labels = sorted(e["shapeData"]["label"] for e in texts)
assert labels == sorted(
["IP1", "IP2", "R=12", "R=15", "D800 L=8.0", "D600 L=6.0", "NO.0", "시점 NO.0"]
)
pairs = [
(a["shapeData"]["label"], b["shapeData"]["label"])
for a, b in combinations(texts, 2)
if overlaps(_rect(a), _rect(b))
]
assert pairs == []
def test_far_spot_draws_leader() -> None:
near = [{"x": 0.0, "y": 0.0}, {"x": 40.0, "y": 0.0, "far": True}]
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import label_entities
placed = place_labels([_label("ip", "IP1", near, (0.0, 0.0))], [(-3.0, -3.0, 3.0, 3.0)])
entities = label_entities("plan_x", placed)
assert [e["type"] for e in entities] == ["Text", "PolyLine"]
def test_blocked_spots_push_farther_with_leader() -> None:
"""141-2 — 후보가 다 막히면 기호에서 2 · 3 배 먼 자리(지시선) · 작은 축척에 몰린 장."""
anchor = (0.0, 0.0)
spot = [{"x": 0.0, "y": -4.0}]
placed = place_labels(
[_label("ip", "IP1", spot, anchor), _label("radius", "R=12", spot, anchor)], []
)
assert placed[0]["spot"] == spot[0]
assert placed[1]["spot"] == {"x": 0.0, "y": -8.0, "far": True}
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import label_entities
entities = label_entities("d", placed)
assert [e["type"] for e in entities] == ["Text", "Text", "PolyLine"]
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""145-3 이음 — B08 구조물 집계표 열마다 `owners` = {사본 주인 id: 그 주인이 낸 값}.
주인 id = `linked_snapshots` 키(`pipe@측점` · 구조물 id · `{id}__{그룹}`) · 열 owners 합 = 열 계 ·
칸을 낸 잇기 사본이 먼저(없으면 시설 본체).
"""
from __future__ import annotations
import pytest
from test_120_b08_drain_summary import out # noqa: F401 — 픽스처(관 밖 횡단배수 견본)
from test_143_1_b08_linked_snapshots import _run as _run_143
from test_143_1_b08_linked_snapshots import root # noqa: F401 — 픽스처(실제 사본 폴더)
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
def _check_sums(result: dict, keys: set[str]) -> None:
for column in result["columns"]:
owners = column["owners"]
assert set(owners) <= keys, column["id"]
assert sum(owners.values()) == pytest.approx(result["total"].get(column["id"], 0.0))
def test_owners_합_열_계_주인은_사본_키(out): # noqa: F811
cols = {c["id"]: c for c in out["columns"]}
_check_sums(out, set(out["linked"]))
# 본체 칸 = 본체 잇기 사본(gboxbody) · 날개벽 = 유입 잇기 · 흙막이 = 유출 잇기
assert cols["pipe:box|body|2.0×2.0"]["owners"] == {"pipe@300.0__gboxbody": 9.6}
assert cols["pipe:box_culvert|wing|inlet"]["owners"] == {"pipe@300.0__gboxin": 1.0}
assert cols["pipe:revet|돌쌓기(메)|2.0"]["owners"] == {"pipe@300.0__gboxout": 10.0}
# 세월교 월류 폭 = 저장된 잇기(gford) · 관 공은 본체(사본 키 = 관 지점 id)
assert cols["pipe:ford|1000|2|width"]["owners"] == {"pipe@400.0__gford": 12.0}
assert cols["pipe:len|800"]["owners"] == {"pipe@300.0": 18.0}
# B05 구조물 = 구조물 id · 그룹 잇기 = {id}__{그룹}
assert cols["pipe:open_ditch"]["owners"] == {"s-ditch": 1.0}
assert cols["pipe:cross_underdrain"]["owners"] == {"s-ud__gcrossud": 1.0}
def test_실제_사본_폴더_키와_같다(root): # noqa: F811
result = _run_143(root)
linked = set(snapshots.linked_snapshots(root))
_check_sums(result, linked)
cols = {c["id"]: c for c in result["columns"]}
assert cols["pipe:len|800"]["owners"] == {"pipe@100.0": 16.0}
assert cols["pipe:len|1000"]["owners"] == {"pipe@500.0__gford": 18.0}