Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into sub_laptop_6

This commit is contained in:
2026-10-08 14:01:26 +09:00
49 changed files with 2056 additions and 943 deletions
@@ -37,4 +37,47 @@ assert.equal(paintColor('#f5f7fa'), '#000000', '밝은 바탕: 기본색은 검
assert.equal(paintColor('#ff0000'), '#ff0000', '밝은 바탕: 빨강 그대로');
assert.notEqual(paintColor('#ffff00'), '#ffff00', '밝은 바탕: 노랑 등 밝은 색은 대비 보정(기존)');
// 커서 입력창 — 칸 · 글 색이 두 테마 모두 달라야(자동색 둘을 쓰면 paintColor 가 같은 색으로 뒤집음)
const { inputFieldColors } = await import(
'../../../B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts'
);
const TOKENS: Record<string, Record<string, string>> = {
light: {
'--cad-chrome-raised': '#ffffff',
'--cad-line': '#e6e2e3',
'--cad-text': '#333333',
'--cad-text-dim': '#615e6e',
},
dark: {
'--cad-chrome-raised': '#251f38',
'--cad-line': '#322a45',
'--cad-text': '#e4e0f0',
'--cad-text-dim': '#b3aec6',
},
};
for (const name of ['light', 'dark']) {
theme = name;
const colors = inputFieldColors(
(token: string, fallback: string) => TOKENS[name][token] ?? fallback
);
assert.equal(
colors.background,
TOKENS[name]['--cad-chrome-raised'],
`${name}: 칸 = 토큰 색 그대로`
);
assert.notEqual(colors.background, colors.text, `${name}: 칸 · 글 색 다름`);
assert.notEqual(colors.background, colors.dimText, `${name}: 칸 · 안내 글 색 다름`);
assert.notEqual(colors.background, colors.border, `${name}: 칸 · 테두리 색 다름`);
// 옛 상수 짝(#000000 칸 · #FFF 글)은 paintColor 를 거치면 같은 색 — 입력창은 건너뛰어야
assert.equal(
paintColor('#000000'),
paintColor('#FFF'),
`${name}: 자동색 두 개는 같은 색으로 뒤집힘`
);
}
// 토큰 못 읽으면 폴백도 칸 · 글 다름
const fallback = inputFieldColors((_token: string, value: string) => value);
assert.notEqual(fallback.background, fallback.text, '폴백: 칸 · 글 색 다름');
assert.ok(!isAutoColor(fallback.background) && !isAutoColor(fallback.text), '폴백: 자동색 아님');
console.log('theme 자동색 시험 통과');
@@ -82,6 +82,12 @@ console.log(
frozen: 1,
empty: "",
}),
pick: m.renderDataTableHtml({
columns: [{ key: "x", label: "X" }],
rows: [{ x: 1 }, { x: 2 }],
total: { x: 3 },
onRowClick: () => undefined,
}),
escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "<b>" }], rows: [{ x: "a&<" }] }),
noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes("<tfoot>"),
}),
@@ -72,9 +72,29 @@ def test_빈_목록():
assert out == [[], [], []]
def test_측점_조합들():
# 95-3 — 한 측점(1cm 안) 조합 전부 · 본체 → 그룹 차례 · 구조물 id 따라감 · 같은 구조물 같은 조합은 한 건
things = [
{"structure_id": "s1", "chainage_m": 40.004, "summary_item": _item("SS1"),
"group_items": {"g1": _item("SS3"), "g2": _item("SS1")}},
{"structure_id": "pipe@40.0", "chainage_m": 40.0, "summary_item": _item("SS1")},
{"structure_id": "s2", "chainage_m": 80.0, "summary_item": _item("SS2")},
] # fmt: skip
out = _run(
f"\nconst things = {json.dumps(things, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify([combosAt(things, 40).map((c) => [c.id, c.structureId]),"
" combosAt(things, 80).length, combosAt(things, 99).length]));"
)
assert out == [
[["system||SS1", "s1"], ["system||SS3", "s1"], ["system||SS1", "pipe@40.0"]],
1,
0,
]
def test_집계표는_공용_표():
# PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님)
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui
assert "frozen: 1" in ui and 'empty: ""' in ui
assert "frozen: 1" in ui and 'empty: ""' in ui and "onRowClick" in ui
assert 'document.createElement("table")' not in ui
@@ -1,15 +1,28 @@
"""61-2 B08 2탭 구조물도 — 탭에 붙음 · 읽기만 · 집계표와 같은 조합 목록 · 안내 글 키."""
"""61-2 · 95-3 B08 구조물도 모달 — 집계표 행에서 열림 · 읽기만 · 집계표와 같은 조합 목록 · 안내 글 키."""
import re
from pathlib import Path
DRAWING = Path("B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8")
SUMMARY = Path("B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
PAGE = Path("B08_Quantity/B08_Quantity_UI_Page.ts").read_text(encoding="utf-8")
LOCALE = Path("ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8")
LOCALE_B2 = Path("ui_template/ui_template_locale_b2.ts").read_text(encoding="utf-8")
def test_sheet_tab_wired():
assert 'tab === "sheet"' in PAGE and "renderStructureSheets(view, projectId)" in PAGE
def test_sheet_tab_gone_modal_wired():
# 구조물도 탭 없음 · 집계표 행 누름이 모달을 늦게 불러 엶
assert '"sheet"' not in PAGE and "renderStructureSheets" not in PAGE
assert "B08_Quantity_Tab_StructureSheet" not in PAGE + LOCALE + LOCALE_B2
assert "onRowClick" in SUMMARY and 'import("./B08_Quantity_UI_Drawing")' in SUMMARY
assert "combosAt(things, station)" in SUMMARY
def test_modal_own_class_and_cleanup():
css = Path("B08_Quantity/B08_Quantity_UI_Drawing.css").read_text(encoding="utf-8")
assert "min(1320px, 96vw)" in css and "--modal-max: 94vh" in css
assert "beforeClose" in DRAWING and "cards.destroy()" in DRAWING
assert 'dialogClass: "b08-sheets"' in DRAWING
def test_read_only():
@@ -19,10 +32,12 @@ def test_read_only():
assert "editable: () => false" in DRAWING
def test_same_columns_as_summary():
assert "summaryColumns(" in DRAWING and "./B08_Quantity_Summary_Data" in DRAWING
def test_copy_first_then_master():
# 같은 항목의 프로젝트 복사본이 있으면 복사본 · 없으면 M02 원본 층
assert "sameItem(own, row)" in DRAWING and "structureFolder(" in DRAWING
assert "readTemplate(layer, part, column, null)" in DRAWING
def test_locale_keys_exist():
for key in set(re.findall(r'L\("([A-Za-z0-9_]+)"\)', DRAWING)):
assert f"{key}:" in LOCALE or key.startswith("B08_Quantity_Summary_"), key
assert f"{key}:" in LOCALE or key.startswith(("B08_Quantity_Summary_", "Common_")), key
@@ -0,0 +1,102 @@
"""B08 수리 표 ← B05 배수유역 연결값 (PLAN 96-1 · 96-3).
견본 세부유역 + 관 지점 → 측점마다 linked(A · L · H · 관경 · 박스 내공 · 월류 폭) ·
그 값으로 계산한 Qd ≈ B05 설계유량 · 표준값은 B05 배수 설정 하나.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402
from B08_Quantity.B08_Quantity_Hydro_Defaults import DEFAULTS, station_rows # noqa: E402
from B08_Quantity.B08_Quantity_Hydro_Site import BASINS_FILE, read_basins # noqa: E402
from common_util.common_util_drainage_detail import size_pipe # noqa: E402
from common_util.common_util_drainage_pipes import PipePoint # noqa: E402
from common_util.common_util_wamis_station import mononobe_idf # noqa: E402
from config import config_system_design as cfg # noqa: E402
R24 = 231.56
BASINS = [
# B05 저장 꼴 — 측점 0.01 반올림 · 면적 0.1 · 표고차 0.01 · 길이 0.1
{"kind": "detail_basin", "chainage_m": 85.05, "area_m2": 10717.0, "flow_length_m": 163.9,
"relief_m": 120.0},
{"kind": "detail_basin", "chainage_m": 173.09, "area_m2": 141145.0, "flow_length_m": 603.3,
"relief_m": 320.0},
{"kind": "detail_basin", "chainage_m": 300.0, "area_m2": 52000.0, "flow_length_m": 410.0,
"relief_m": 0.0},
] # fmt: skip
for _b in BASINS:
_out = size_pipe(
_b["area_m2"], max(_b["relief_m"], 0.1), _b["flow_length_m"], mononobe_idf(R24)
)
_b.update(design_flow_m3s=_out["design_flow_m3s"], tc_minutes=_out["tc_minutes"])
POINTS = [
PipePoint(chainage_m=300.004, facility="box_culvert",
options={"body_width_m": 2.0, "body_height_m": 1.5}),
PipePoint(chainage_m=85.052, options={"pipe_diameter_mm": "1000", "pipe_kind": "파형강관"}),
PipePoint(chainage_m=173.09, facility="ford_bridge", options={"ford_width_m": 10}),
PipePoint(chainage_m=500.0, options={"pipe_diameter_mm": 800}), # 유역 짝 없음
] # fmt: skip
def test_측점마다_연결값() -> None:
rows = station_rows(POINTS, BASINS)
assert [r["chainage_m"] for r in rows] == [85.052, 173.09, 300.004, 500.0]
pipe, ford, box, lone = (r["linked"] for r in rows)
assert pipe == {
"area_km2": 0.010717,
"length_m": 163.9,
"height_m": 120.0,
"diameter_mm": 1000.0,
}
assert ford == {"area_km2": 0.141145, "length_m": 603.3, "height_m": 320.0,
"overflow_width_m": 10.0} # fmt: skip
# 표고차 0 은 빈칸(저장 검사 gt 0) · 박스는 내공
assert box == {"area_km2": 0.052, "length_m": 410.0, "box_width_m": 2.0, "box_height_m": 1.5}
# 유역 짝이 없으면 시설 치수만 · B05 참고값 없음
assert lone == {"diameter_mm": 800.0} and rows[3]["b05"] is None
assert rows[0]["b05"] == {"design_flow_m3s": BASINS[0]["design_flow_m3s"], "tc_minutes": 5.0}
def test_잇지_않는_칸() -> None:
for row in station_rows(POINTS, BASINS):
assert not {"slope_denominator", "pipe_kind", "n"} & set(row["linked"])
def test_basins_를_안_주면_linked_없음() -> None:
rows = station_rows(POINTS)
assert all(r["linked"] is None and r["b05"] is None for r in rows)
@pytest.mark.parametrize("index", [0, 1])
def test_연결값으로_계산한_Qd_는_B05_설계유량(index: int) -> None:
row = station_rows(POINTS, BASINS)[index]
out = compute({**DEFAULTS, **row["linked"], "r24_mm": R24, "sheet": row["sheet"]})
assert out["design_flow"] == pytest.approx(row["b05"]["design_flow_m3s"], abs=1e-4)
assert out["tc_hr"] * 60 == pytest.approx(row["b05"]["tc_minutes"], abs=0.05)
def test_표준값은_B05_배수_설정() -> None:
assert DEFAULTS["runoff"] == cfg.DRAINAGE_RUNOFF_COEFFICIENT == 0.8
assert DEFAULTS["factor"] == cfg.DRAINAGE_DESIGN_FLOW_FACTOR == 2.0
assert DEFAULTS["return_period"] == cfg.DRAINAGE_DESIGN_RETURN_PERIOD_YR == 100
assert DEFAULTS["tc_min_minutes"] == cfg.DRAINAGE_TC_MIN_MINUTES == 5.0
def test_세부유역_파일_읽기(tmp_path: Path) -> None:
assert read_basins(tmp_path) == [] # 분석 저장 전
features = [{"type": "Feature", "properties": b, "geometry": None} for b in BASINS]
features.append({"type": "Feature", "properties": {"kind": "pipe", "chainage_m": 85.05}})
path = tmp_path / BASINS_FILE
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"type": "FeatureCollection", "features": features}), "utf-8")
assert [b["chainage_m"] for b in read_basins(tmp_path)] == [85.05, 173.09, 300.0]
+19 -1
View File
@@ -101,7 +101,25 @@ def test_소유_측점에만_싣는다():
layouts = _LAYOUTS.read_text(encoding="utf-8")
assert "pipeOwnerChainage" in layouts, "소유 측점을 안 가리고 있다"
assert "section.culvert?.chainage_m" in layouts, "관이 놓인 자리를 안 읽고 있다"
py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(encoding="utf-8")
py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(
encoding="utf-8"
)
ts = (PROJECT_ROOT / "common_util" / "common_util_culvert_sets.ts").read_text(encoding="utf-8")
assert '"chainage_m": pipe_chainage' in py
assert "chainage_m: pipeChainage" in ts
def test_집수정_실제_모양이_같은_길로_정본에_실린다():
"""98-2 — 실제로 선 집수정 모양(patch · 자동 지형 규칙 결과)이 다단 벽과 같은 줄로 정본에 감.
TS 가 줄에 싣고(`ExtraWallRow.inlet_basin`) 서버가 합칠 때 받아야 함 — 한쪽만 있으면 조용히 버려짐.
"""
layouts = _LAYOUTS.read_text(encoding="utf-8")
block = layouts[layouts.index("export interface ExtraWallRow") :]
assert "inlet_basin: BasinShape | null" in block[: block.index("}")]
assert "inlet_basin: culvert.basin?.shape ?? null" in layouts
prebuild = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text(
encoding="utf-8"
)
merged = prebuild[prebuild.index("extra_walls = [") :][:900]
assert '"inlet_basin": row.get("inlet_basin")' in merged
+141 -57
View File
@@ -1,48 +1,89 @@
"""B07 용지도 — 필지마다 지번·지목·면적·소유자 한 묶음 · 빈 값은 빈 줄 (59-6)."""
"""B07 용지도 — 필지마다 지번·지목·면적·소유자 상자 + 지시선 (59-6 · 68-2 · 93-3).
import pytest
① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선
② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번
③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침
"""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import (
JIBUN_LAYER_ID,
MM,
build_landuse_drawing,
import json
from pathlib import Path
from shapely.geometry import LineString, Point
from shapely.geometry import box as box_polygon
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
build_plan_kind_drawing,
layer_id,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
PARCEL_FONT,
ROUTE_CLEAR_M,
clip_parcels,
place_labels,
)
def _square(x0: float, size: float) -> list[tuple[float, float]]:
return [(x0, 0.0), (x0 + size, 0.0), (x0 + size, size), (x0, size), (x0, 0.0)]
BOX = (0.0, 0.0, 400.0, 300.0)
def test_one_label_group_per_parcel() -> None:
def _square(x0: float, y0: float, size: float) -> list[list[float]]:
return [[x0, y0], [x0 + size, y0], [x0 + size, y0 + size], [x0, y0 + size], [x0, y0]]
def _geojson(tmp_path: Path, features: list[tuple[dict, dict]]) -> Path:
path = tmp_path / "parcels.geojson"
payload = {
"type": "FeatureCollection",
"features": [
{"type": "Feature", "properties": props, "geometry": geometry}
for props, geometry in features
],
}
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return path
def _polygon(ring: list[list[float]]) -> dict:
return {"type": "Polygon", "coordinates": [ring]}
def _rect(item: dict) -> tuple[float, float, float, float]:
width, height = balloon_size(item["lines"], PARCEL_FONT)
cx, cy = item["center"]
return (cx - width / MM / 2, cy - height / MM / 2, cx + width / MM / 2, cy + height / MM / 2)
def test_one_box_per_parcel(tmp_path: Path) -> None:
full = {"pnu": "A", "jibun": "95전", "jimok": "전", "parea": 4916, "owner_nm": "개인"}
split = {"pnu": "B", "jibun": "산1임", "jimok": "임야", "parea": 12.4, "owner_nm": ""}
parcels = [
{"ring": _square(0.0, 40.0), "props": full},
# 도곽에 잘려 두 조각이 된 필지 — 정보는 큰 조각에 한 번만
{"ring": _square(100.0, 10.0), "props": split},
{"ring": _square(200.0, 30.0), "props": split},
# 도곽을 통째로 감싼 필지 — 경계 없이 정보만
{"ring": [], "props": {"pnu": "C", "jibun": "산77"}, "label_at": (500.0, 500.0)},
]
drawing = build_landuse_drawing("landuse", "용지도", [], [], parcels, [], [])
texts = [e for e in drawing["entities"] if e.get("layerId") == JIBUN_LAYER_ID]
labels = [e["shapeData"]["label"] for e in texts]
assert labels == ["95전", "전", "4,916㎡", "개인", "산1임", "임야", "12㎡", "산77"]
groups = {round(e["shapeData"]["basePoint"]["x"], 6) for e in texts}
assert len(groups) == 3
# 쪼개진 필지는 큰 조각(200~230 m) 가운데에
split_x = {
e["shapeData"]["basePoint"]["x"] for e in texts if e["shapeData"]["label"] == "산1임"
two_parts = {
"type": "MultiPolygon",
"coordinates": [[_square(250.0, 200.0, 20.0)], [_square(300.0, 150.0, 80.0)]],
}
assert split_x.pop() == pytest.approx(215.0 * MM)
path = _geojson(tmp_path, [(full, _polygon(_square(20.0, 20.0, 120.0))), (split, two_parts)])
parcels = clip_parcels(path, "EPSG:4326", BOX)
items = place_labels(parcels, [], BOX)
assert sorted(item["lines"] for item in items) == [
["95전", "전", "4,916㎡", "개인"],
["산1임", "임야", "12㎡", ""],
]
# 쪼개진 필지는 큰 조각(300~380 · 150~230)에 지시선 끝
(b,) = [item for item in items if item["lines"][0] == "산1임"]
assert 300.0 <= b["anchor"][0] <= 380.0 and 150.0 <= b["anchor"][1] <= 230.0
# 빈 줄은 글 없이 자리만 — 도면 글은 일곱
drawing = build_plan_kind_drawing(
"plan_landuse", "평면도(용지도)", BOX, {"parcel_info": {"items": items}}
)
texts = [
e["shapeData"]["label"]
for e in drawing["entities"]
if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text"
]
assert sorted(texts) == sorted(["95전", "전", "4,916㎡", "개인", "산1임", "임야", "12㎡"])
def test_zone_inside_one_parcel_is_named_once(tmp_path) -> None:
"""작은 구역이 큰 필지 하나 안에 통째로 — 경계선이 없어도 어느 필지인지 한 번 적는다 (68-2)."""
import json
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_parcels
def test_zone_inside_one_parcel_is_named_once(tmp_path: Path) -> None:
props = {
"pnu": "P1",
"sgg_nm": "봉화군",
@@ -53,27 +94,70 @@ def test_zone_inside_one_parcel_is_named_once(tmp_path) -> None:
"parea": 1234567,
"owner_nm": "국",
}
big = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]]
geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": props,
"geometry": {"type": "Polygon", "coordinates": [big]},
}
],
}
path = tmp_path / "parcels.geojson"
path.write_text(json.dumps(geojson, ensure_ascii=False), encoding="utf-8")
parcels = _clip_parcels(path, "EPSG:4326", (4.0, 4.0, 6.0, 6.0))
assert len(parcels) == 1 and parcels[0]["ring"] == []
route = [(4.5, 4.5), (5.5, 5.5)]
drawing = build_landuse_drawing("landuse", "용지도", route, [], parcels, [], [])
labels = [
e["shapeData"]["label"] for e in drawing["entities"] if e.get("layerId") == JIBUN_LAYER_ID
path = _geojson(tmp_path, [(props, _polygon(_square(-500.0, -500.0, 2000.0)))])
parcels = clip_parcels(path, "EPSG:4326", BOX)
assert len(parcels) == 1 and parcels[0]["lines"] == [] and parcels[0]["whole"]
(item,) = place_labels(parcels, [(150.0, 100.0), (250.0, 200.0)], BOX)
assert item["lines"] == [
"구역 전체가 이 필지 안 (봉화군 재산면 갈산리)",
"산77-1임",
"임야",
"1,234,567㎡",
"국",
]
notes = [label for label in labels if label.startswith("구역 전체가 이 필지 안")]
assert notes == ["구역 전체가 이 필지 안 (봉화군 재산면 갈산리)"]
assert labels[1:] == ["산77-1임", "임야", "1,234,567㎡", "국"]
def test_boxes_clear_route_and_each_other(tmp_path: Path) -> None:
# 노선이 가운데를 가로지르는 필지 열둘(4 × 3)
features = [
(
{
"pnu": f"P{i}{j}",
"jibun": f"{i}{j}-1",
"jimok": "임야",
"parea": 9999,
"owner_nm": "홍길동",
},
_polygon(_square(i * 100.0, j * 100.0, 100.0)),
)
for i in range(4)
for j in range(3)
]
path = _geojson(tmp_path, features)
route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)]
items = place_labels(clip_parcels(path, "EPSG:4326", BOX), route, BOX)
assert len(items) == 12
clear = LineString(route).buffer(ROUTE_CLEAR_M)
rects = [_rect(item) for item in items]
for item, rect in zip(items, rects):
assert not box_polygon(*rect).intersects(clear) # 상자 · 노선 겹침 0
assert not clear.contains(Point(item["anchor"])) # 지시선 끝도 노선 밖
assert BOX[0] <= rect[0] and rect[2] <= BOX[2] and BOX[1] <= rect[1] and rect[3] <= BOX[3]
for index, rect in enumerate(rects):
for other in rects[index + 1 :]:
assert not box_polygon(*rect).intersects(box_polygon(*other))
# 도면(종이 mm)에서도 상자 테두리가 중심선과 안 닿음
pieces = {
"centerline": {"lines": [[list(p) for p in route]]},
"parcel_info": {"items": items},
}
drawing = build_plan_kind_drawing("plan_landuse", "평면도(용지도)", BOX, pieces)
(center,) = [e for e in drawing["entities"] if e.get("layerId") == layer_id("centerline")]
center_line = _polyline(center)
# 상자 = 변 넷 · 지시선 = 계단 둘
outlines = [
e
for e in drawing["entities"]
if e.get("layerId") == layer_id("parcel_info") and len(e.get("children") or []) == 4
]
assert len(outlines) == 12
for outline in outlines:
assert _polyline(outline).distance(center_line) > 0
def _polyline(entity: dict) -> LineString:
children = entity["children"]
points = [children[0]["shapeData"]["startPoint"]] + [
c["shapeData"]["endPoint"] for c in children
]
return LineString([(p["x"], p["y"]) for p in points])
+122 -8
View File
@@ -6,21 +6,31 @@
④ 고른 조각만 도면 층으로 얹힘
⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 ·
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호
⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층
⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층
"""
import json
import math
from pathlib import Path
import ezdxf
import pytest
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import (
curve_values,
dms,
ip_blocks,
ip_table_piece,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
DEFAULT_KINDS,
build_plan_kind_drawing,
layer_id,
slope_pieces,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import drawing_to_dxf
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
_kinds_dir,
@@ -123,7 +133,13 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No
def test_kinds_add_delete_persist(tmp_path: Path) -> None:
root = tmp_path / "project"
assert [k["name"] for k in list_kinds(root)] == ["지형", "노선배치도", "대피소", "라이다"]
assert [k["name"] for k in list_kinds(root)] == [
"지형",
"노선배치도",
"대피소",
"라이다",
"용지도",
]
added = add_kind(root, "내 평면도", ["centerline", "station", "nope"])
assert added["pieces"] == ["centerline", "station"]
with pytest.raises(ValueError):
@@ -132,14 +148,14 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None:
add_kind(root, "빈 것", [])
delete_kind(root, "terrain") # 기본 넷도 지움
again = list_kinds(root) # 다시 읽어 같음
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "내 평면도"]
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "용지도", "내 평면도"]
assert again[-1]["pieces"] == ["centerline", "station"]
with pytest.raises(FileNotFoundError):
delete_kind(root, "terrain")
for kind in list(again):
delete_kind(root, kind["id"])
assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음)
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar"}
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar", "landuse"}
def test_refuge_marks_same_spot(tmp_path: Path) -> None:
@@ -184,16 +200,16 @@ def test_split_runs_switch_cut_fill_and_stay_joined() -> None:
def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None:
root = tmp_path / "project"
outline = {"road_edge", "fill_outline", "cut_outline"}
for (
kind
) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
# 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로)
for kind in DEFAULT_KINDS:
if kind["id"] != "landuse":
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
folder = _kinds_dir(root)
folder.mkdir(parents=True)
(folder / "old.json").write_text(
json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8"
)
(kind,) = list_kinds(root)
kind = next(k for k in list_kinds(root) if k["id"] == "old") # 용지도는 이관으로 더해짐
assert set(kind["pieces"]) == outline | {"station"}
@@ -230,3 +246,101 @@ def test_drainage_symbol_piece() -> None:
mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")]
assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2
assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine)
def test_ip_table_example_values_and_dms() -> None:
ia = 94 + 59 / 60 + 58.38 / 3600 # 예시 그림 IP1
values = curve_values(15.0, ia)
assert dms(ia) == "94-59-58.38"
assert [f"{values[k]:.5f}" for k in ("tl", "cl", "sl")] == ["16.36950", "24.87082", "7.20271"]
assert dms(1 + 59 / 60 + 59.9999 / 3600) == "2-00-00.00" # 초 60.00 올림
assert dms(5 + 3 / 60 + 7.2 / 3600) == "5-03-07.20"
# ㄱ자 노선 — (0,0)→(100,0)→(100,100) · 모서리 R 15 곡선(가운데 누가거리 100) · 앞에 노선 밖 옛 곡선
_ROUTE = [(0.0, 0.0, 0.0), (85.0, 85.0, 0.0), (115.0, 100.0, 15.0), (200.0, 100.0, 100.0)]
_CURVES = [
{"apex": [500.0, 500.0], "start": [490.0, 500.0], "end": [500.0, 510.0], "radius_m": 10.0},
{"apex": [100.0, 0.0], "start": [85.0, 0.0], "end": [100.0, 15.0], "radius_m": 15.0},
]
def test_ip_blocks_one_sheet_each_and_ends() -> None:
first = ip_blocks(_CURVES, _ROUTE, 1, 2, 0.0, 100.0)
second = ip_blocks(_CURVES, _ROUTE, 2, 2, 100.0, 200.0)
assert [b["name"] for b in first] == ["BP"] # 경계 IP 는 뒤 장에만
assert [b["name"] for b in second] == ["2", "EP"] # 번호 = 곡선표 차례(옛 곡선은 빠짐)
ip = second[0]
assert dms(ip["ia"]) == "90-00-00.00" and ip["tl"] == pytest.approx(15.0)
assert [b["name"] for b in ip_blocks(_CURVES, _ROUTE, 1, 1, 0.0, 200.0)] == ["BP", "2", "EP"]
def test_ip_table_drawing_clips_lines_under_table(tmp_path: Path) -> None:
box = (-20.0, -20.0, 300.0, 200.0)
contour = [[-20.0, 0.0], [300.0, 0.0]] # 표(왼쪽 아래 · 높이 48 m) 밑을 지나는 등고선
pieces = {
"contour": {"lines": [contour]},
"centerline": {"lines": [[[0.0, 100.0], [9.0, 100.0]]]},
}
pieces["ip_table"] = ip_table_piece(
pieces, _CURVES, _ROUTE, box, {"number": 1, "start_m": 0.0, "end_m": 200.0}, 1
)
# IP 표 칸(x 29.2 ~ 72.4 · y -14 ~ 34)만 빠짐 · BP · EP 칸(3행)은 y 0 위라 안 자름
assert pieces["contour"]["lines"] == [[[-20.0, 0.0], [29.2, 0.0]], [[72.4, 0.0], [300.0, 0.0]]]
assert pieces["centerline"]["lines"] == [[[0.0, 100.0], [9.0, 100.0]]] # 표 밖은 그대로
drawing = build_plan_kind_drawing("plan_ip", "시험", box, pieces)
mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("ip_table")]
assert [e["type"] for e in mine] == ["Table"] * 3
bp, ip, _ep = (e["shapeData"] for e in mine)
assert len(bp["rowHeights"]) == 3 and len(ip["rowHeights"]) == 8
assert bp["cells"][1][2]["text"] == "0.00000" and ip["cells"][1][2]["text"] == "0.00000"
assert ip["cells"][2][2]["text"] == "100.00000" # Y = 동(x)
assert ip["cells"][3][1]["text"] == "90-00-00.00"
assert {layer["id"] for layer in drawing["layers"]} >= {layer_id("ip_table")}
assert "ip_table" in next(k for k in DEFAULT_KINDS if k["id"] == "route")["pieces"]
# DXF — 표 → 선 + 글(IP 묶음: 바깥 4 · 세로 10 · 가로 20 · 글 17)
raw, skipped = drawing_to_dxf({"entities": [mine[1]], "layers": drawing["layers"]})
(tmp_path / "ip.dxf").write_bytes(raw)
space = ezdxf.readfile(tmp_path / "ip.dxf").modelspace()
assert skipped == {} and len(space.query("LINE")) == 34 and len(space.query("TEXT")) == 17
assert {"90-00-00.00", "15.00000"} <= {t.dxf.text for t in space.query("TEXT")}
def test_landuse_kind_added_once_to_old_folder(tmp_path: Path) -> None:
root = tmp_path / "project"
folder = _kinds_dir(root)
folder.mkdir(parents=True)
for order, kind in enumerate(DEFAULT_KINDS[:4]): # 93장 앞에 내려 둔 기본 넷
(folder / f"{kind['id']}.json").write_text(
json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8"
)
kinds = list_kinds(root)
assert [k["id"] for k in kinds][-1] == "landuse"
assert kinds[-1]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"]
assert len(list_kinds(root)) == 5 # 두 번 읽어도 한 번
delete_kind(root, "landuse")
assert "landuse" not in [k["id"] for k in list_kinds(root)] # 지운 것은 안 살아남
def test_landuse_pieces_become_layers() -> None:
pieces = {
"parcel": {"lines": [[[0.0, 0.0], [10.0, 0.0]]]},
"emd": {"lines": [[[0.0, 5.0], [10.0, 5.0]]]},
"sgg": {"lines": [[[0.0, 9.0], [10.0, 9.0]]]},
"parcel_info": {
"items": [
{"lines": ["1-1", "임야", "", "국"], "anchor": [5.0, 2.0], "center": [20.0, 20.0]}
]
},
}
drawing = build_plan_kind_drawing(
"plan_landuse", "평면도(용지도)", (-50.0, -10.0, 50.0, 50.0), pieces
)
names = {layer["id"] for layer in drawing["layers"]}
assert {layer_id(p) for p in ("parcel", "parcel_info", "emd", "sgg")} <= names
labels = [
e["shapeData"]["label"]
for e in drawing["entities"]
if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text"
]
assert labels == ["1-1", "임야", "국"]
+9
View File
@@ -92,3 +92,12 @@ def test_빈_칸_글_풍선_꾸밈_옵션(r):
assert '<td class="is-empty"></td>' in o # empty="" — 「-」 대신 빈칸
assert '<th scope="col" rowspan="1" class="is-missing" title="풍선">메쌓기</th>' in o
assert 'rowspan="2" class="is-frozen is-edge" style="left:0px" title="측점">측점</th>' in o
def test_줄_누름_옵션일_때만_data_i(r):
# 95-3 — onRowClick 을 주면 본문 줄에만 data-i · is-clickable(합계 줄 제외) · 안 주면 글 그대로
pick = r["pick"]
assert '<tr class="is-clickable" data-i="0">' in pick
assert '<tr class="is-alt is-clickable" data-i="1">' in pick
assert pick.count("data-i=") == 2
assert "data-i" not in r["opts"] and "is-clickable" not in r["foot"]