feat(B06): 상세 창 CAD 칸 표 아래 접기 · 구조물 조합 줄 미리 넣음(265-1 · 265-2)

- 상세 창 차례 = 그림 → 표 → CAD 칸 · 칸 머리 한 줄 접기(공용 ui-collapsible) · 열 때 일반 측점 접힘 · 구조물 측점 폄 · 처음 펼 때 칸 만듦
- 미리 넣는 글 = 그 측점 구조물(관 지점 · 구조물 배치 기준 측점)마다 지정된 구조물 조합 이름 줄 · 미지정 = 「이름 조합 미지정」 · 배관 = 원형 마크(본체 줄 없음) · 옛 관보호공 · 관유출부보강 짜 맞춘 글 버림
- B07 짝(CulvertNote.py) 같은 규칙 · 정본 structures.json · 구조물정의 이름까지 읽음
- 시험 test_265_note_cad 넓힘 · test_260 · test_150 기대값

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 16:48:15 +09:00
co-authored by Claude Opus 5.5
parent c47e9f8615
commit a1d63ebfd6
9 changed files with 647 additions and 307 deletions
@@ -1,10 +1,13 @@
"""B07 횡단도 표 위 CAD 칸 — 횡단배수 미리 넣음(260-4 · 요구 162-21).
"""B07 횡단도 표 위 CAD 칸 — 미리 넣음(260-4 · 265-2 · 요구 162-21 · 164-5).
⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `culvertNoteEntities` ·
`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py`.
⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `noteEntities` · `structureNoteLines` ·
`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py` ·
`test_265_note_cad.py`.
B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 그림을 놓음 · 사용자가 지운 칸
(빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저
(모달이 처음 열 때 옮기는 차례와 같음).
그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된
구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」).
좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
"""
@@ -14,16 +17,26 @@ import re
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
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`) — 관 자리와 측점이 같은 자리로 보는 폭
# 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": "ㄷ형"}
# 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리
_PIPE_BODY_LINK = "grv6lny"
_LAYERS = ("system", "company", "personal")
_GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
#: (관 누가거리, 세트, 옵션) — 원형 마크 재료
Pipe = tuple[float, dict[str, Any], dict[str, Any]]
#: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items}` — TS `NoteThing`
Thing = dict[str, Any]
def _js_round(value: float) -> int:
@@ -31,74 +44,13 @@ def _js_round(value: float) -> int:
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 _number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def _whole_metres(value: Any) -> str:
"""관 길이 = 사사오입 정수(265-3 · TS `pipeLengthWhole` · B08 「관매설」과 같은 값)."""
return f"{_js_round(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"
return f"{_js_round(value)}m" if _number(value) else ""
def station_no_label(chainage_m: float, interval_m: float) -> str:
@@ -112,6 +64,59 @@ def station_no_label(chainage_m: float, interval_m: float) -> str:
return f"NO.{number}" if abs(rest) < 0.05 else f"NO.{number}+{_js_round(rest)}"
def _summary_item(value: Any) -> dict[str, Any] | None:
"""TS `readSummaryItem` — 모양이 맞으면 그대로 · 틀리면 None."""
if not isinstance(value, dict):
return None
owner, key = value.get("owner"), value.get("key")
ok = (
value.get("layer") in _LAYERS
and (owner is None or isinstance(owner, str))
and isinstance(key, str)
and key != ""
and isinstance(value.get("name"), str)
)
return value if ok else None
def _group_items(value: Any) -> list[tuple[str, dict[str, Any]]]:
"""TS `readGroupItems` — 그룹 id · 항목 모양이 맞는 것만(차례 그대로)."""
if not isinstance(value, dict):
return []
return [
(gid, item)
for gid, raw in value.items()
if _GROUP_ID.match(str(gid)) and (item := _summary_item(raw))
]
def things_at(things: list[Thing], chainage_m: float) -> list[Thing]:
"""그 측점에 놓인 구조물 — TS `thingsAt`."""
return [thing for thing in things if abs(thing["at"] - chainage_m) < PIPE_MATCH_M]
def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]:
"""구조물 조합 줄 — TS `structureNoteLines` 짝.
본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌.
"""
lines: list[str] = []
for thing in things_at(things, chainage_m):
own = _summary_item(thing.get("summary_item"))
slots = ([("", own)] if own else []) + _group_items(thing.get("group_items"))
if not slots:
if not thing["pipe"]:
lines.append(f"{thing['name']} 조합 미지정")
continue
names = [
item["name"] or item["key"]
for gid, item in slots
if not (thing["pipe"] and gid in ("", _PIPE_BODY_LINK))
]
lines += list(dict.fromkeys(names))
return lines
def _shape(index: int, kind: str, shape_data: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"culvert-note:{index}",
@@ -141,17 +146,10 @@ def _text(index: int, label: str, x: float, y: float, align: str = "left") -> di
)
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,
def _pipe_mark(
chainage_m: float, culvert: dict[str, Any] | None, design: dict[str, Any] | None
) -> list[dict[str, Any]]:
"""횡단배수 미리 넣음 — 이 측점에 놓인(주인) 배수관만 · 상자형 · 숨은 관 · 이웃 = 빈 목록.
`culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `options` = 그 관의 `pipe_points` 옵션.
"""
"""원형 마크 — TS `pipeMark`(주인 배수관만 · 상자형 · 숨은 관 · 이웃 = 없음)."""
if not isinstance(culvert, dict) or culvert.get("type") != "pipe":
return []
if culvert.get("body") or culvert.get("hidden_pipe"):
@@ -166,18 +164,7 @@ def culvert_note_entities(
f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m",
f"L={_whole_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 = [
return [
_shape(0, "Circle", {"center": {"x": cx, "y": cy}, "radius": radius}),
_shape(
1,
@@ -187,38 +174,84 @@ def culvert_note_entities(
"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
] + [_text(2 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside)]
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):
def note_entities(
chainage_m: float,
culvert: dict[str, Any] | None,
design: dict[str, Any] | None,
station: str,
lines: list[str],
) -> list[dict[str, Any]]:
"""미리 넣음 — TS `noteEntities` 짝(원형 마크 + 측점 · 조합 줄 · 마크 없으면 왼쪽 끝부터).
`culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `lines` = `structure_note_lines`.
"""
mark = _pipe_mark(chainage_m, culvert, design)
if not mark and not lines:
return []
pipes = []
for point in parse_pipe_points(document.get("points")):
x = 15 if mark else 0
start = len(mark)
return mark + [
_text(start + i, line, x, -_LINE * (i + 0.5)) for i, line in enumerate([station, *lines])
]
def _things(points: list[Any], structures: list[Any], names: dict[str, str]) -> list[Thing]:
"""글 줄 재료 — 관 지점 → 구조물 배치 차례(TS `loadNoteThings` 와 같은 차례)."""
things: list[Thing] = [
{
"name": names.get(point.facility, point.facility),
"pipe": point.facility == PIPE_FACILITY_PIPE,
"at": float(point.chainage_m),
"summary_item": point.summary_item,
"group_items": point.group_items,
}
for point in points
]
for item in structures:
at = item.chainage_m if item.chainage_m is not None else item.start_m
if not _number(at):
continue
things.append(
{
"name": names.get(item.type_id, item.type_id),
"pipe": False,
"at": float(at),
"summary_item": item.summary_item,
"group_items": item.group_items,
}
)
return things
def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]:
"""정본 재료 — (원형 마크 배수관 · 관경 안 고름 = 없음, 글 줄 재료 = 관 지점 + 구조물 배치)."""
try:
document = json.loads(pipe_points_file(project_root).read_text(encoding="utf-8"))
points = parse_pipe_points(document.get("points"))
except (OSError, json.JSONDecodeError):
points = []
pipes: list[Pipe] = []
for point in 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
_revision, structures = load_structures(str(project_root))
names = {kind.type_id: kind.name for kind in load_structure_types()}
return pipes, _things(points, structures, names)
def with_culvert_note(
def with_structure_note(
design: dict[str, Any] | None,
chainage_m: float,
pipes: list[tuple[float, dict[str, Any], dict[str, Any]]],
sources: tuple[list[Pipe], list[Thing]],
station: str,
) -> dict[str, Any] | None:
"""CAD 칸이 없고 옛 글도 없는 주인 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로.
"""CAD 칸이 없고 옛 글도 없는 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로.
설계가 없는 측점(dict 아님)은 그대로 — 표 · 계획선 셈이 설계 없음으로 가는 길을 안 바꿈.
"""
@@ -226,11 +259,11 @@ def with_culvert_note(
return design
if str(design.get("structure_note") or "").strip():
return design
pipes, things = sources
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)
spec = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))[1] if near else None
lines = structure_note_lines(things, chainage_m)
entities = note_entities(chainage_m, spec, design, station, lines)
if not entities:
return design
return {**design, "structure_note_cad": {"entities": entities}}