feat(b07): 안 연 측점 횡단배수 미리 넣음 — 파이썬 짝 (260-4)

- 새 B07_DesignDetail_Engine_Cad_CulvertNote = TS culvertNoteEntities · stationNoLabel 짝(두 파일 머리 표시)
- 횡단 장 만들 때 CAD 칸 없음 · 옛 글 없음 · 배수관 주인 측점 = 정본 pipe_points 로 미리 넣음 칸을 얹은 사본
- 지운 칸(빈 엔티티) · 옛 글 · 설계 없음 · 이웃 측점 = 그대로
- 시험 test_260_note_cad 거울 9쌍(같은 입력 → 같은 엔티티) · 정본 읽기 · B07 그림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
This commit is contained in:
2026-10-11 14:16:46 +09:00
co-authored by Claude Opus 5.5
parent 83735340ab
commit 96c9eaed37
5 changed files with 418 additions and 4 deletions
@@ -7,6 +7,9 @@
* (`_Engine_Cad_Table.cross_note_cad_entities`).
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수
* 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면.
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `culvertNoteEntities` ·
* `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 측점은 B07 이 그것으로 그림 ·
* 거울 시험 `test_260_note_cad.py`.
* 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
* ========================================================================== */
@@ -0,0 +1,231 @@
"""B07 횡단도 표 위 CAD 칸 — 횡단배수 미리 넣음(260-4 · 요구 162-21).
⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `culvertNoteEntities` ·
`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py`.
B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 그림을 놓음 · 사용자가 지운 칸
(빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저
(모달이 처음 열 때 옮기는 차례와 같음).
좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
"""
import json
import math
import re
from pathlib import Path
from typing import Any
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file
from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points
# TS `PIPE_MATCH_M`(`design_selection`) — 관 자리와 측점이 같은 자리로 보는 폭
PIPE_MATCH_M = 0.51
_LAYER_ID = "기본"
_FONT = 2.0
_LINE = 3.0
_COLOR = "#ff7f7f"
_BASIN_FORMS = {"I": "I형", "L": "L형", "U": "ㄷ형"}
def _js_round(value: float) -> int:
"""JS `Math.round` — .5 는 위로."""
return math.floor(value + 0.5)
def _one(value: Any) -> str:
ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
return f"{value:.1f}" if ok else ""
def _metres(value: Any) -> str:
return f"{_one(value)}m" if _one(value) else ""
def _trim(value: float) -> str:
rounded = _js_round(value * 100) / 100
return str(int(rounded)) if rounded == int(rounded) else str(rounded)
def _bond(form: Any) -> str:
"""돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로."""
text = str(form) if form is not None else ""
return "찰" if "찰" in text else "메" if "메" in text else text
def _side_line(side: Any, title: str) -> str | None:
if not isinstance(side, dict) or not side.get("structure"):
return None
basin = side["structure"] == "집수정"
form = (
_BASIN_FORMS.get(side.get("basin_form") or "", "")
if basin
else _bond(side.get("revet_form"))
)
height = side.get("basin_height_m" if basin else "revet_height_m")
length = side.get("basin_length_m" if basin else "revet_length_m")
spec = ",".join(part for part in (form, f"H={_one(height)}") if part)
return f"관보호공({title}) {side['structure']}({spec}) L={_metres(length)}"
def _area(value: Any) -> float:
"""JS `Number()` 꼴 — 빈 글 = 0 · 못 읽음 = nan."""
if value is None:
return math.nan
if isinstance(value, str) and not value.strip():
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return math.nan
def _chute_line(options: dict[str, Any], fallback: str | None) -> str | None:
picked = sorted(
int(hit.group(1))
for key, value in options.items()
if (hit := re.match(r"^chute_add_(\d+)_structure$", key))
and str(value if value is not None else "").strip()
)
head = f"chute_add_{picked[0]}_" if picked else None
structure = str(options[f"{head}structure"]).strip() if head else fallback
if not structure:
return None
bond_raw = options.get(f"{head}bond") if head else None
bond = _bond(bond_raw if bond_raw is not None else "") if head else ""
area = _area(options.get(f"{head}area")) if head else math.nan
spec = ",".join(part for part in (structure, bond) if part)
return f"관유출부보강({spec}) {_trim(area) if area > 0 else ''}m2"
def station_no_label(chainage_m: float, interval_m: float) -> str:
"""측점 이름(대문자 NO · 거리 정수) — TS `stationNoLabel` 짝."""
safe = interval_m if interval_m > 0 else 1.0
number = math.floor((chainage_m + 1e-6) / safe)
rest = chainage_m - number * safe
if rest >= safe - 0.05:
number += 1
rest = 0.0
return f"NO.{number}" if abs(rest) < 0.05 else f"NO.{number}+{_js_round(rest)}"
def _shape(index: int, kind: str, shape_data: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"culvert-note:{index}",
"type": kind,
"lineColor": _COLOR,
"lineWidth": 1,
"layerId": _LAYER_ID,
"shapeData": shape_data,
}
def _text(index: int, label: str, x: float, y: float, align: str = "left") -> dict[str, Any]:
return _shape(
index,
"Text",
{
"label": label,
"basePoint": {"x": x, "y": y},
"options": {
"textDirection": {"x": 1, "y": 0},
"textAlign": align,
"textColor": _COLOR,
"fontSize": _FONT,
"fontFamily": "sans-serif",
},
},
)
def culvert_note_entities(
chainage_m: float,
culvert: dict[str, Any] | None,
design: dict[str, Any] | None,
station: str,
options: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""횡단배수 미리 넣음 — 이 측점에 놓인(주인) 배수관만 · 상자형 · 숨은 관 · 이웃 = 빈 목록.
`culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `options` = 그 관의 `pipe_points` 옵션.
"""
if not isinstance(culvert, dict) or culvert.get("type") != "pipe":
return []
if culvert.get("body") or culvert.get("hidden_pipe"):
return []
owner = culvert.get("chainage_m")
if abs((owner if isinstance(owner, (int, float)) else chainage_m) - chainage_m) >= PIPE_MATCH_M:
return []
cx, cy, radius = 6.5, -9.0, 6.5
length = design.get("pipe_length_m") if isinstance(design, dict) else None
inside = [
"배수관",
f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m",
f"L={_metres(length)}",
]
chute = culvert.get("chute") if isinstance(culvert.get("chute"), dict) else {}
right = [
line
for line in (
station,
_side_line(culvert.get("inlet"), "유입"),
_side_line(culvert.get("outlet"), "유출"),
_chute_line(options or {}, chute.get("structure")),
)
if line
]
entities = [
_shape(0, "Circle", {"center": {"x": cx, "y": cy}, "radius": radius}),
_shape(
1,
"Line",
{
"startPoint": {"x": cx, "y": cy - radius},
"endPoint": {"x": cx, "y": cy - radius - _LINE},
},
),
]
entities += [
_text(2 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside)
]
entities += [_text(5 + i, line, 15, -_LINE * (i + 0.5)) for i, line in enumerate(right)]
return entities
def load_pipe_notes(project_root: Path) -> list[tuple[float, dict[str, Any], dict[str, Any]]]:
"""정본 `pipe_points.json` 의 배수관 — (관 누가거리, 세트, 옵션) · 관경 안 고름 = 관 없음."""
path = pipe_points_file(project_root)
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
pipes = []
for point in parse_pipe_points(document.get("points")):
options = dict(point.options or {})
if point.facility != PIPE_FACILITY_PIPE or options.get("pipe_diameter_mm") in (None, ""):
continue
spec = {**_culvert_set(options), "chainage_m": float(point.chainage_m)}
pipes.append((float(point.chainage_m), spec, options))
return pipes
def with_culvert_note(
design: dict[str, Any] | None,
chainage_m: float,
pipes: list[tuple[float, dict[str, Any], dict[str, Any]]],
station: str,
) -> dict[str, Any] | None:
"""CAD 칸이 없고 옛 글도 없는 주인 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로.
설계가 없는 측점(dict 아님)은 그대로 — 표 · 계획선 셈이 설계 없음으로 가는 길을 안 바꿈.
"""
if not isinstance(design, dict) or design.get("structure_note_cad") is not None:
return design
if str(design.get("structure_note") or "").strip():
return design
near = [pipe for pipe in pipes if abs(pipe[0] - chainage_m) < PIPE_MATCH_M]
if not near:
return design
_at, spec, options = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))
entities = culvert_note_entities(chainage_m, spec, design, station, options)
if not entities:
return design
return {**design, "structure_note_cad": {"entities": entities}}
@@ -25,6 +25,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
load_pipe_notes,
with_culvert_note,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
station_no_label as station_no_label_upper,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
build_longitudinal_drawing,
longitudinal_chunks,
@@ -383,17 +390,22 @@ def _cross_section_input(
chainage: int | float,
design: dict[str, Any] | None,
overlay: tuple[float, float, float, float] | None = None,
pipes: list[tuple[float, dict[str, Any], dict[str, Any]]] | None = None,
) -> dict[str, Any] | None:
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다.
`pipes` = 정본 배수관(`load_pipe_notes`) — CAD 칸 없는 주인 측점에 미리 넣음(260-4)."""
path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage)
if not path.is_file():
return None
source = _read_json(path)
interval = infer_station_interval(longitudinal.get("stations") or [])
at = float(source.get("chainage_m", chainage))
return {
"chainage": chainage,
"source": source,
"design": design,
# 표 · 계획선은 저장 설계 그대로 · 그림만 미리 넣음 칸을 얹은 사본(260-4)
"design": with_culvert_note(design, at, pipes or [], station_no_label_upper(at, interval)),
"design_line": _cross_design_line(longitudinal_path, source, design),
"quantity_table": _quantity_table(source, design),
"title": station_no_label(float(source.get("chainage_m", chainage)), interval),
@@ -416,6 +428,7 @@ def _read_cross_sheet(
if sheet is None:
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
longitudinal = _read_json(longitudinal_path)
pipes = load_pipe_notes(project_root)
sections = [
section
for section in (
@@ -425,6 +438,7 @@ def _read_cross_sheet(
chainage,
designs.get(chainage),
overlays.get(chainage),
pipes,
)
for chainage in sheet["chainages"]
)
+11 -2
View File
@@ -1,6 +1,6 @@
/* PLAN 260 단위검증 헬퍼 — [상세] 표 위 CAD 칸 · 옛 글 옮김 · 횡단배수 미리 넣음.
* `B06_Section_UI_Cross_Detail_Cad.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다(CAD · 세션 · 서버 = 흉내).
* 결과는 표준출력에 JSON 한 벌. 확장자 `.cjs` 까닭은 `helper_108_drag_box_hit.cjs` 머리와 같다.
* 결과는 표준출력에 JSON 한 벌 · 인자 `mirror` = 표준입력 입력으로 거울 결과(260-4). 확장자 `.cjs` 까닭은 `helper_108_drag_box_hit.cjs` 머리와 같다.
*/
const fs = require("fs");
const path = require("path");
@@ -108,4 +108,13 @@ const out = {
cad.stationNoLabel(1439.97, 20),
],
};
process.stdout.write(JSON.stringify(out));
// 거울(260-4) — 시험이 표준입력으로 준 같은 입력 [{section, station, options, chainage, interval}]
// → TS 엔티티 · 측점 이름(파이썬 짝 `_Engine_Cad_CulvertNote` 와 대조)
if (process.argv[2] === "mirror") {
const cases = JSON.parse(fs.readFileSync(0, "utf8"));
const mirrored = cases.map((item) => ({
entities: cad.culvertNoteEntities(item.section, item.station, item.options),
station: cad.stationNoLabel(item.chainage, item.interval),
}));
process.stdout.write(JSON.stringify(mirrored));
} else process.stdout.write(JSON.stringify(out));
+157
View File
@@ -294,3 +294,160 @@ def test_CAD_칸이_없으면_옛_글_줄_그대로():
noted = _draw({"structure_note": "L형 측구\n집수정 보강"})
labels = [e["shapeData"]["label"] for e in noted["entities"] if e["type"] == "Text"]
assert labels == ["L형 측구", "집수정 보강"]
# ── 260-4 안 연 측점 = B07 이 파이썬 짝으로 넣음 ─────────────────────────
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402
culvert_note_entities,
load_pipe_notes,
station_no_label,
with_culvert_note,
)
_OPTIONS = {
"pipe_diameter_mm": 1200,
"inlet_type": "기슭막이",
"inlet_revet_form": "돌쌓기(찰)",
"inlet_revet_height_m": 2,
"inlet_revet_length_m": 10,
"outlet_type": "기슭막이",
"outlet_revet_form": "돌쌓기(메)",
"outlet_revet_height_m": 2,
"outlet_revet_length_m": 10,
"chute_add_1_structure": "돌붙임",
"chute_add_1_bond": "찰붙임",
"chute_add_1_area": 10,
}
_NO_CHUTE = {k: v for k, v in _OPTIONS.items() if not k.startswith("chute")}
def _case(options: dict, pipe_at: float, chainage: float, design: dict | None, **spec) -> dict:
culvert = {**_culvert_set(options), "chainage_m": pipe_at, **spec}
return {
"section": {"chainage_m": chainage, "culvert": culvert, "design": design},
"station": station_no_label(chainage, 20.0),
"options": options,
"chainage": chainage,
"interval": 20.0,
}
_MIRROR_CASES = [
_case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}),
_case(_NO_CHUTE, 1433.0, 1433.0, {}),
_case(
{
**_NO_CHUTE,
"inlet_type": "집수정",
"inlet_structure": "L",
"inlet_basin_height_m": 1.7,
"inlet_basin_length_m": 2,
},
1433.0,
1433.0,
{"pipe_length_m": 9.5},
),
_case({**_NO_CHUTE, "chute_add_2_structure": "도수로", "chute_add_2_area": ""}, 80, 80, None),
_case({**_OPTIONS, "pipe_diameter_mm": 800, "chute_add_1_area": 12.345}, 12.5, 12.5, {}),
_case({**_OPTIONS, "inlet_revet_form": "콘크리트"}, 440.241, 440.0, {"pipe_length_m": 7}),
_case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}),
_case(_OPTIONS, 1433.0, 1433.0, {}, body="ford"),
_case(_OPTIONS, 1433.0, 1433.0, {}, hidden_pipe=True),
]
def _without_id(entities: list[dict]) -> list[dict]:
return [{k: v for k, v in entity.items() if k != "id"} for entity in entities]
def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티():
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"],
input=json.dumps(_MIRROR_CASES, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
mirrored = json.loads(proc.stdout)
assert len(mirrored) == len(_MIRROR_CASES)
for case, ts in zip(_MIRROR_CASES, mirrored, strict=True):
section = case["section"]
py = culvert_note_entities(
section["chainage_m"],
section["culvert"],
section["design"],
case["station"],
case["options"],
)
assert _without_id(py) == _without_id(ts["entities"]), case["station"]
assert station_no_label(case["chainage"], case["interval"]) == ts["station"]
# 대조가 빈 목록끼리만이 아님 — 주인 측점 여섯은 그림이 있음 · 이웃 · 세월교 · 숨은 관 = 빈값
assert [bool(item["entities"]) for item in mirrored] == [True] * 6 + [False] * 3
labels = [e["shapeData"].get("label") for e in mirrored[4]["entities"]]
assert "Φ800m/m" in labels and "관유출부보강(돌붙임,찰) 12.35m2" in labels
def _write_pipes(root: Path, points: list[dict]) -> None:
path = pipe_points_file(root)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8")
def test_정본_배수관만_읽음(tmp_path):
_write_pipes(
tmp_path,
[
{"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS},
{"chainage_m": 80.0, "lonlat": [0, 0], "facility": "pipe", "options": {}},
{"chainage_m": 90.0, "lonlat": [0, 0], "facility": "ford_bridge", "options": {}},
],
)
pipes = load_pipe_notes(tmp_path)
assert [pipe[0] for pipe in pipes] == [1433.0] # 관경 안 고름 · 세월교 = 빼
assert pipes[0][1]["chainage_m"] == 1433.0 and pipes[0][2] == _OPTIONS
assert load_pipe_notes(tmp_path / "없음") == []
def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_그대로(tmp_path):
_write_pipes(
tmp_path,
[{"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}],
)
pipes = load_pipe_notes(tmp_path)
design = {"pipe_length_m": 22, "paved": False}
seeded = with_culvert_note(design, 1433.0, pipes, "NO.71+13")
assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜
labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]]
assert labels[2:] == [
"배수관",
"Φ1200m/m",
"L=22.0m",
"NO.71+13",
"관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m",
"관보호공(유출) 기슭막이(메,H=2.0) L=10.0m",
"관유출부보강(돌붙임,찰) 10m2",
]
cleared = {**design, "structure_note_cad": {"entities": []}}
assert with_culvert_note(cleared, 1433.0, pipes, "x") is cleared
old = {**design, "structure_note": "옛 글"}
assert with_culvert_note(old, 1433.0, pipes, "x") is old
assert with_culvert_note(None, 1433.0, pipes, "x") is None
assert with_culvert_note(design, 1440.0, pipes, "x") is design
def test_B07_은_안_연_측점에_미리_넣음을_표_위에_그림(tmp_path):
_write_pipes(
tmp_path,
[{"chainage_m": 20.2, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}],
)
design = with_culvert_note({"pipe_length_m": 22}, 20.0, load_pipe_notes(tmp_path), "NO.1")
drawn = _draw(design)
labels = [e["shapeData"]["label"] for e in drawn["entities"] if e["type"] == "Text"]
assert "NO.1" in labels and "관유출부보강(돌붙임,찰) 10m2" in labels
plain_top = _table(_draw({"pipe_length_m": 22}))["shapeData"]["origin"]["y"]
drop = plain_top - _table(drawn)["shapeData"]["origin"]["y"]
assert drop == pytest.approx(cross_note_block_height(design)) and drop > 15