feat(b07): 횡단도 표 위 CAD 칸 배치 · 없으면 옛 글 줄 (260-3)

- design.structure_note_cad 엔티티 = 범위 왼쪽 위를 표 왼쪽 · 윗변에 맞춤 · 표 층 · 배율 따름
- 표 · 테두리 · 시트 칸 높이 = 범위 높이 · 칸이 비면 아무것도 안 놓음 · 칸 없으면 옛 글 줄
- 시험 test_260_note_cad 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:10:20 +09:00
co-authored by Claude Opus 5.5
parent 29803ada06
commit a00bf2786a
4 changed files with 237 additions and 11 deletions
@@ -524,10 +524,9 @@ def build_cross_drawing(
# 표 모듈은 이 모듈의 직렬화 헬퍼를 쓰므로 순환을 피해 함수 안에서 들여온다.
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
_cross_note_entities,
_cross_table_entities,
cross_note_height,
cross_note_lines,
cross_note_block_entities,
cross_note_block_height,
cross_table_height,
cross_table_width,
)
@@ -550,7 +549,7 @@ def build_cross_drawing(
if quantity_table is not None:
stack = (
cross_table_height(design, table_scale)
+ cross_note_height(cross_note_lines(design), table_scale)
+ cross_note_block_height(design, table_scale)
+ 8.0
)
oy = table_bottom + stack + own_half_height * CROSS_MM
@@ -580,12 +579,11 @@ def build_cross_drawing(
table_bottom = oy - half_height
if quantity_table is not None:
table_top = oy - half_height - 8.0
# 표 위 글(150-3) — 줄 수만큼 표를 내림
note_lines = cross_note_lines(design)
# 표 위 글(150-3) · CAD 칸(260-3) — 그 높이만큼 표를 내림
entities.extend(
_cross_note_entities(drawing_id, note_lines, table_top, center_x, table_scale)
cross_note_block_entities(drawing_id, design, table_top, center_x, table_scale)
)
table_top -= cross_note_height(note_lines, table_scale)
table_top -= cross_note_block_height(design, table_scale)
entities.extend(
_cross_table_entities(
drawing_id, quantity_table, table_top, title_label, center_x, design, table_scale
@@ -34,8 +34,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
polyline_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
cross_note_height,
cross_note_lines,
cross_note_block_height,
cross_table_height,
cross_table_width,
)
@@ -106,7 +105,7 @@ def section_block_size(
top = half_height + _BORDER_PAD_MM
below = half_height + _BORDER_PAD_MM
if with_table:
note_h = cross_note_height(cross_note_lines(design_spec), table_scale)
note_h = cross_note_block_height(design_spec, table_scale)
below += 8.0 + note_h + cross_table_height(design_spec, table_scale)
return (width, top + below)
@@ -401,6 +401,96 @@ def _cross_note_entities(
]
# 표 위 CAD 칸(260-3) — B06 [상세] 빈 CAD(`design.structure_note_cad`) 엔티티를 표 왼쪽 위에 맞춤.
# 칸이 있으면(엔티티가 비었어도) 옛 글 대신 이것 · 없으면 옛 글 줄 그대로.
def cross_note_cad(design: dict[str, Any] | None) -> list[dict[str, Any]] | None:
"""설계의 표 위 CAD 엔티티 — 칸이 없으면 None(옛 글 줄로)."""
cad = design.get("structure_note_cad") if isinstance(design, dict) else None
if not isinstance(cad, dict):
return None
entities = cad.get("entities")
return [e for e in entities if isinstance(e, dict)] if isinstance(entities, list) else []
def _text_width(label: str, font: float) -> float:
"""글 폭 어림(mm) — 한글 · 한자 = 글자 크기 · 그 밖 = 0.6 배."""
return sum(font if ord(char) >= 0x2E80 else 0.6 * font for char in label)
def note_cad_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None:
"""CAD 칸 범위 (min_x, min_y, max_x, max_y) — 글은 기준점이 세로 가운데(CAD `textBaseline`)라
글 높이 · 폭을 맞춤 쪽으로 더함 · 그 밖은 `entities_bbox`."""
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox
boxes = []
for entity in entities:
shape = entity.get("shapeData") or {}
base = shape.get("basePoint")
if entity.get("type") == "Text" and isinstance(base, dict):
options = shape.get("options") or {}
font = float(options.get("fontSize") or 0.0)
width = _text_width(str(shape.get("label") or ""), font)
x, y = float(base["x"]), float(base["y"])
left = {"center": x - width / 2.0, "right": x - width}.get(options.get("textAlign"), x)
boxes.append((left, y - font / 2.0, left + width, y + font / 2.0))
elif (box := entities_bbox([entity])) is not None:
boxes.append(box)
if not boxes:
return None
return (
min(b[0] for b in boxes),
min(b[1] for b in boxes),
max(b[2] for b in boxes),
max(b[3] for b in boxes),
)
def _on_layer(entity: dict[str, Any], layer_id: str) -> dict[str, Any]:
entity["layerId"] = layer_id
for child in entity.get("children") or []:
_on_layer(child, layer_id)
return entity
def cross_note_block_height(design: dict[str, Any] | None, scale: float = 1.0) -> float:
"""표 위 칸 높이(mm) — CAD 칸 = 범위 높이 × 배율 · 없으면 옛 글 줄 높이."""
cad = cross_note_cad(design)
if cad is None:
return cross_note_height(cross_note_lines(design), scale)
box = note_cad_bbox(cad)
return (box[3] - box[1]) * scale if box else 0.0
def cross_note_block_entities(
drawing_id: str,
design: dict[str, Any] | None,
top: float,
center_x: float = 0.0,
scale: float = 1.0,
) -> list[dict[str, Any]]:
"""표 위 칸 엔티티 — CAD 칸 = 범위 왼쪽 위를 표 왼쪽 끝 · `top` 에 맞춰 옮김(× 배율 · 표 층 ·
id `{도면}:notecad:{n}`) · 없으면 옛 글 줄(`_cross_note_entities`)."""
cad = cross_note_cad(design)
if cad is None:
return _cross_note_entities(drawing_id, cross_note_lines(design), top, center_x, scale)
box = note_cad_bbox(cad)
if box is None:
return []
from B07_DesignDetail.B07_DesignDetail_Engine_Template import _transform_entity
left = center_x - _CROSS_TABLE_WIDTH * scale / 2.0
dx, dy = left - box[0] * scale, top - box[3] * scale
return [
_on_layer(
_transform_entity(entity, f"{drawing_id}:notecad:{index}", scale, dx, dy),
CROSS_TABLE_LAYER_ID,
)
for index, entity in enumerate(cad)
]
def cross_table_width(scale: float = 1.0) -> float:
"""수량 산출표 가로 폭(mm)."""
return _CROSS_TABLE_WIDTH * scale
+139
View File
@@ -155,3 +155,142 @@ def test_옛_글은_줄마다_Text_로_옮김(out):
def test_측점_이름은_B07_꼴(out):
assert out["station"] == ["NO.71+13", "NO.71", "NO.72"]
# ── 260-3 B07 표 위 ─────────────────────────────────────────────────────
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( # noqa: E402
CROSS_TABLE_LAYER_ID,
FRAME_LAYER_ID,
build_cross_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import section_block_size # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( # noqa: E402
cross_note_block_height,
cross_note_cad,
note_cad_bbox,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox # noqa: E402
_SOURCE = {
"chainage_m": 20.0,
"samples": [
{"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.3 * x}
for x in range(-10, 11)
],
}
_QTABLE = {"ground": 100.0, "planned": 99.5}
def _text(label: str, x: float, y: float, align: str = "left", font: float = 2.0) -> dict:
return {
"id": label,
"type": "Text",
"lineColor": "#ff7f7f",
"lineWidth": 1,
"layerId": "기본",
"shapeData": {
"label": label,
"basePoint": {"x": x, "y": y},
"options": {"textAlign": align, "fontSize": font, "textColor": "#ff7f7f"},
},
}
# 미리 넣음과 같은 꼴 — 원(중심 6.5,-9 · 반지름 6.5) · 지시선 · 오른쪽 글(도면 좌표 아무 자리여도 됨)
_CIRCLE = {
"id": "c",
"type": "Circle",
"lineColor": "#ff7f7f",
"lineWidth": 1,
"layerId": "기본",
"shapeData": {"center": {"x": 106.5, "y": 41.0}, "radius": 6.5},
}
_CAD = {
"structure_note_cad": {
"entities": [_CIRCLE, _text("NO.1", 115.0, 48.5), _text("관유출부보강", 115.0, 45.5)],
"layers": [{"id": "기본", "name": "기본"}],
}
}
def _draw(design: dict | None) -> dict:
return build_cross_drawing(_SOURCE, "cross_00020m", None, design, _QTABLE, "No.1")
def _table(drawing: dict) -> dict:
return next(e for e in drawing["entities"] if e["type"] == "Table")
def _frame_height(drawing: dict) -> float:
frame = [e for e in drawing["entities"] if e.get("layerId") == FRAME_LAYER_ID]
_x0, y0, _x1, y1 = entities_bbox(frame)
return y1 - y0
def test_CAD_칸_범위는_글_높이_폭까지():
"""글 기준점 = 세로 가운데 · 왼쪽 맞춤 폭 = 한글 글자 크기 · 원 = 중심 ± 반지름."""
box = note_cad_bbox(_CAD["structure_note_cad"]["entities"])
assert box == pytest.approx((100.0, 34.5, 115.0 + 6 * 2.0, 49.5))
assert note_cad_bbox([_text("AB", 10.0, 0.0, "center")]) == pytest.approx((8.8, -1, 11.2, 1))
assert note_cad_bbox([]) is None
assert cross_note_cad(None) is None and cross_note_cad({"structure_note": "x"}) is None
def test_CAD_칸은_표_왼쪽_위_맞춤_표는_범위_높이만큼_내려감():
plain = _draw(None)
drawn = _draw(_CAD)
height = 49.5 - 34.5
assert cross_note_block_height(_CAD) == pytest.approx(height)
plain_top = _table(plain)["shapeData"]["origin"]["y"]
drawn_top = _table(drawn)["shapeData"]["origin"]["y"]
assert plain_top - drawn_top == pytest.approx(height)
placed = [e for e in drawn["entities"] if e.get("id") in _placed_ids()]
left = _table(drawn)["shapeData"]["origin"]["x"]
box = note_cad_bbox(placed)
# 좌상 맞춤 — 범위 왼쪽 = 표 왼쪽 · 범위 위 = 옛 표 윗변(글 없을 때 표 자리)
assert box[0] == pytest.approx(left) and box[3] == pytest.approx(plain_top)
assert box[1] == pytest.approx(drawn_top)
# 표 층으로 · 크기 그대로(배율 1) · 글 색 그대로 · id 는 도면마다 새로
assert {e["layerId"] for e in placed} == {CROSS_TABLE_LAYER_ID}
circle = next(e for e in placed if e["type"] == "Circle")
assert circle["shapeData"]["radius"] == 6.5
assert {e["lineColor"] for e in placed} == {"#ff7f7f"}
assert "c" not in {e["id"] for e in placed}
# 블록(테두리) · 시트 칸 크기도 같은 규칙
assert _frame_height(drawn) - _frame_height(plain) == pytest.approx(height)
_w, plain_h = section_block_size(_SOURCE, None)
_w, cad_h = section_block_size(_SOURCE, None, design=_CAD)
assert cad_h - plain_h == pytest.approx(height)
_w, scaled_h = section_block_size(_SOURCE, None, design=_CAD, table_scale=2.0)
_w, plain2_h = section_block_size(_SOURCE, None, table_scale=2.0)
assert scaled_h - plain2_h == pytest.approx(2 * height)
def _placed_ids() -> set[str]:
"""옮겨 놓은 CAD 칸 엔티티 id — 결정적 seed `{도면}:notecad:{n}`."""
from uuid import uuid5
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _ENTITY_NS
return {str(uuid5(_ENTITY_NS, f"cross_00020m:notecad:{n}")) for n in range(3)}
def test_CAD_칸이_있으면_옛_글_대신():
both = _draw({**_CAD, "structure_note": "옛 글"})
labels = [e["shapeData"]["label"] for e in both["entities"] if e["type"] == "Text"]
assert "옛 글" not in labels and "NO.1" in labels
def test_CAD_칸이_비면_아무것도_안_놓음_옛_글도_안_씀():
plain = _draw(None)
empty = _draw({"structure_note_cad": {"entities": []}, "structure_note": "옛 글"})
assert _table(plain)["shapeData"] == _table(empty)["shapeData"]
assert not [e for e in empty["entities"] if e["type"] == "Text"]
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형 측구", "집수정 보강"]