diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 98a66b450..89ed9443c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -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 diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 1dc94c77b..aecd97a15 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -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) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index 85a559e3e..fe102f202 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -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 diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index 774dbc8d8..2b408a5c4 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -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형 측구", "집수정 보강"]