From 96c9eaed37b7ba9b087bec63e761ee86de03e30b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:16:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(b07):=20=EC=95=88=20=EC=97=B0=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=ED=9A=A1=EB=8B=A8=EB=B0=B0=EC=88=98=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=84=A3=EC=9D=8C=20=E2=80=94=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=EC=8D=AC=20=EC=A7=9D=20(260-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 B07_DesignDetail_Engine_Cad_CulvertNote = TS culvertNoteEntities · stationNoLabel 짝(두 파일 머리 표시) - 횡단 장 만들 때 CAD 칸 없음 · 옛 글 없음 · 배수관 주인 측점 = 정본 pipe_points 로 미리 넣음 칸을 얹은 사본 - 지운 칸(빈 엔티티) · 옛 글 · 설계 없음 · 이웃 측점 = 그대로 - 시험 test_260_note_cad 거울 9쌍(같은 입력 → 같은 엔티티) · 정본 읽기 · B07 그림 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B06_Section_UI_Cross_Detail_Cad.ts | 3 + ...B07_DesignDetail_Engine_Cad_CulvertNote.py | 231 ++++++++++++++++++ .../B07_DesignDetail_Router_Support.py | 18 +- resources/tester/helper_260_note_cad.cjs | 13 +- resources/tester/test_260_note_cad.py | 157 ++++++++++++ 5 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts index 6633cfba7..3cb8b04b4 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -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) · 아래로 음수. * ========================================================================== */ diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py new file mode 100644 index 000000000..53dc81fa1 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py @@ -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}} diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index a351e69c7..17056cc23 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -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"] ) diff --git a/resources/tester/helper_260_note_cad.cjs b/resources/tester/helper_260_note_cad.cjs index 372e2a599..4cd84f09e 100644 --- a/resources/tester/helper_260_note_cad.cjs +++ b/resources/tester/helper_260_note_cad.cjs @@ -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)); diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index 2b408a5c4..5a85aed87 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -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