diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 04b121aaf..735e8371c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -75,6 +75,44 @@ def _line_entity( } +def _circle_entity( + seed: str, center: tuple[float, float], radius: float, layer_id: str, color: str +) -> dict[str, Any]: + return { + "id": str(uuid5(_ENTITY_NS, seed)), + "type": "Circle", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": {"center": {"x": center[0], "y": center[1]}, "radius": radius}, + } + + +def _arc_entity( + seed: str, + center: tuple[float, float], + radius: float, + start_angle: float, + end_angle: float, + layer_id: str, + color: str, +) -> dict[str, Any]: + return { + "id": str(uuid5(_ENTITY_NS, seed)), + "type": "Arc", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": { + "center": {"x": center[0], "y": center[1]}, + "radius": radius, + "startAngle": start_angle, + "endAngle": end_angle, + "counterClockwise": True, + }, + } + + def polyline_entity( drawing_id: str, points: list[tuple[float, float]], @@ -386,6 +424,8 @@ def _cross_line_entities( CROSS_MM = 1000.0 / DRAWING_SCALE_CROSS # 단면을 그리는 가로 범위: 설계선(절·성토)·구조물이 원지반과 갈라지는 구간 + 여유(m). CROSS_SECTION_MARGIN_M = 1.5 +# 단면 블록 테두리 ↔ 안쪽 그림 간격(mm) — 한 장에 최대한 많이 담는다. +CROSS_BORDER_PAD_MM = 1.0 # 원지반과 "만난다"고 볼 표고 차이(m) — 이보다 작으면 붙어 있는 것으로 본다. _TOUCH_TOLERANCE_M = 0.02 @@ -507,7 +547,7 @@ def build_cross_drawing( # 표는 단면 중심 아래에 붙인다 (좌표는 mm). 그리는 창이 좌우 비대칭이면 # 단면 중심이 원점과 어긋나므로 표도 같은 중심을 쓴다. center_x = ox + (x0 + x1) / 2.0 * CROSS_MM - half_height = own_half_height * CROSS_MM + 5.0 + half_height = own_half_height * CROSS_MM table_bottom = oy - half_height if quantity_table is not None: table_top = oy - half_height - 8.0 @@ -520,10 +560,11 @@ def build_cross_drawing( if cell_frame is not None: frame_left, frame_bottom, frame_right, frame_top = cell_frame else: - frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0) + pad = CROSS_BORDER_PAD_MM + frame_x = max((x1 - x0) / 2.0 * CROSS_MM + pad, cross_table_width() / 2.0 + pad) frame_left, frame_right = center_x - frame_x, center_x + frame_x - frame_top = oy + half_height + 4.0 - frame_bottom = table_bottom - 4.0 + frame_top = oy + half_height + pad + frame_bottom = table_bottom - pad corners = [ (frame_left, frame_bottom), (frame_right, frame_bottom), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py index b1c76fa1f..4557d4b7f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py @@ -13,10 +13,8 @@ import math from typing import Any -from uuid import uuid5 from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( - _ENTITY_NS, DESIGN_COLOR, DESIGN_LAYER_ID, DRAWING_FORMAT, @@ -28,6 +26,8 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( TABLE_LABEL_COLOR, TABLE_LINE_COLOR, TABLE_VALUE_COLOR, + _arc_entity, + _circle_entity, _design_profile_points, _format, _interpolate, @@ -45,6 +45,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( entities_bbox, frame_entities, scale_fields, + usable_area, ) from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import DRAWING_SCALE_LONG_H, DRAWING_SCALE_LONG_V @@ -76,79 +77,50 @@ _FONT_SIZE = 2.2 _TICK_LEN = 0.8 # 측점 눈금 길이 -def _circle_entity( - seed: str, center: tuple[float, float], radius: float, layer_id: str, color: str -) -> dict[str, Any]: +def _span(stations: list[dict[str, Any]], index: int, take: int) -> dict[str, float]: return { - "id": str(uuid5(_ENTITY_NS, seed)), - "type": "Circle", - "lineColor": color, - "lineWidth": 1, - "layerId": layer_id, - "shapeData": {"center": {"x": center[0], "y": center[1]}, "radius": radius}, + "start_m": float(stations[index]["chainage_m"]), + "end_m": float(stations[index + take - 1]["chainage_m"]), } -def _arc_entity( - seed: str, - center: tuple[float, float], - radius: float, - start_angle: float, - end_angle: float, - layer_id: str, - color: str, -) -> dict[str, Any]: - return { - "id": str(uuid5(_ENTITY_NS, seed)), - "type": "Arc", - "lineColor": color, - "lineWidth": 1, - "layerId": layer_id, - "shapeData": { - "center": {"x": center[0], "y": center[1]}, - "radius": radius, - "startAngle": start_angle, - "endAngle": end_angle, - "counterClockwise": True, - }, - } +def _fits_sheet(longitudinal: dict[str, Any], span: dict[str, float]) -> bool: + """이 구간을 고정 축척으로 그렸을 때 A1 작도 영역에 드는가.""" + bbox = entities_bbox(_long_content(longitudinal, "probe", span)) + if bbox is None: + return True + usable_w, usable_h = usable_area() + return bbox[2] - bbox[0] <= usable_w + 1e-6 and bbox[3] - bbox[1] <= usable_h + 1e-6 def longitudinal_chunks(longitudinal: dict[str, Any]) -> list[dict[str, Any]]: """측점 30개 단위 분할 목록. 각 항목: {id, label, start_m, end_m}. - 30개 이하면 단일 도면(id="longitudinal", 기존 manifest 호환). 초과 시 + 한 장이면 단일 도면(id="longitudinal", 기존 manifest 호환). 여러 장이면 경계 측점 1개를 중복시켜 도면 간 선이 이어지게 한다(납품 도면 관례). + 축척(H 1/1,000 · V 1/200)은 모든 장이 같다 — 작도 영역을 넘으면 측점을 줄여 + 장을 더 나눌 뿐 줄이지 않는다. """ stations = sorted(_stations(longitudinal), key=lambda s: float(s["chainage_m"])) if not stations: return [{"id": "longitudinal", "label": "종단도", "start_m": None, "end_m": None}] - if len(stations) <= LONG_SPLIT_STATION_COUNT: - return [ - { - "id": "longitudinal", - "label": "종단도", - "start_m": float(stations[0]["chainage_m"]), - "end_m": float(stations[-1]["chainage_m"]), - } - ] - chunks: list[dict[str, Any]] = [] - stride = LONG_SPLIT_STATION_COUNT - 1 # 경계 1측점 중복 + spans: list[dict[str, float]] = [] index = 0 - number = 1 - while index < len(stations) - 1: - chunk_stations = stations[index : index + LONG_SPLIT_STATION_COUNT] - chunks.append( - { - "id": f"longitudinal_{number}", - "label": f"종단도({number})", - "start_m": float(chunk_stations[0]["chainage_m"]), - "end_m": float(chunk_stations[-1]["chainage_m"]), - } - ) - index += stride - number += 1 - return chunks + while True: + take = min(LONG_SPLIT_STATION_COUNT, len(stations) - index) + # ponytail: 한 측점씩 줄이며 다시 잰다 — 넘치는 장에서만 돎 + while take > 2 and not _fits_sheet(longitudinal, _span(stations, index, take)): + take -= 1 + spans.append(_span(stations, index, take)) + if index + take >= len(stations): + break + index += take - 1 # 경계 1측점 중복 + if len(spans) == 1: + return [{"id": "longitudinal", "label": "종단도", **spans[0]}] + return [ + {"id": f"longitudinal_{number}", "label": f"종단도({number})", **span} + for number, span in enumerate(spans, start=1) + ] def _long_table_values( @@ -600,10 +572,10 @@ def _long_table_entities( return entities -def build_longitudinal_drawing( +def _long_content( longitudinal: dict[str, Any], drawing_id: str, chunk: dict[str, Any] -) -> dict[str, Any]: - """종단도 청크 하나를 지반선+계획선+그래프 축·격자+측점 테이블로 만든다.""" +) -> list[dict[str, Any]]: + """종단도 청크 하나의 지반선+계획선+그래프 축·격자+측점 테이블(도각 제외).""" start_m = chunk.get("start_m") end_m = chunk.get("end_m") @@ -667,7 +639,16 @@ def build_longitudinal_drawing( ) ) + return entities + + +def build_longitudinal_drawing( + longitudinal: dict[str, Any], drawing_id: str, chunk: dict[str, Any] +) -> dict[str, Any]: + """종단도 청크 하나를 지반선+계획선+그래프 축·격자+측점 테이블로 만든다.""" + entities = _long_content(longitudinal, drawing_id, chunk) # A1 도각: 콘텐츠가 이미 종이 mm라 도각도 실치수(1:1)로 두고 위치만 맞춘다. + # 그림 좌하단 = 작도영역 좌하단 — 장마다 같은 자리에서 시작한다. bbox = entities_bbox(entities) if bbox: entities.extend( @@ -675,6 +656,7 @@ def build_longitudinal_drawing( drawing_id, bbox, fit=False, + bottom_left=True, fields={ "도면명": "종단면도", **scale_fields(("H", DRAWING_SCALE_LONG_H), ("V", DRAWING_SCALE_LONG_V)), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 17b072707..73e91308c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -11,6 +11,7 @@ from typing import Any from B05_Profile.B05_Profile_Engine_Sections import cross_key from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + CROSS_BORDER_PAD_MM, CROSS_MM, CROSS_TABLE_LAYER_ID, DESIGN_LAYER_ID, @@ -44,7 +45,7 @@ CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?") # 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. # 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거). _BLOCK_GAP_MM = 0.0 -_BORDER_PAD_MM = 4.0 +_BORDER_PAD_MM = CROSS_BORDER_PAD_MM def _design_points(design_line: list[Any] | None) -> list[tuple[float, float]]: @@ -72,7 +73,7 @@ def section_block_size( x0, x1 = _section_window(ground, design) ground_clipped = _clip_polyline(ground, x0, x1) ys = [y for _x, y in ground_clipped] + [y for x, y in design if x0 <= x <= x1] - half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM + 5.0 + half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM width = 2.0 * max( (x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index 07e264f8a..447f5037f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -481,12 +481,14 @@ def frame_entities( template_name: str = A1_TEMPLATE, fit: bool = True, fields: dict[str, str] | None = None, + bottom_left: bool = False, ) -> list[dict[str, Any]]: """콘텐츠 bbox를 감싸는 도각 프레임 엔티티 목록(잠금 레이어). 템플릿 없으면 빈 목록. fit=False면 도각을 **실치수(1:1)** 로 두고 위치만 맞춘다 — 콘텐츠가 이미 종이 밀리미터로 그려진 척도 고정 도면(종단·횡단)용. fit=True는 척도가 없는 도면을 - 도각에 맞춰 늘리던 기존 동작이다. + 도각에 맞춰 늘리던 기존 동작이다. bottom_left=True면 콘텐츠 좌하단을 작도영역 + 좌하단에 맞춘다(기본은 중심 맞춤). """ template = _load_template(template_name) if not template: @@ -513,6 +515,8 @@ def frame_entities( # 콘텐츠 중심 = 내부 작도 영역 중심이 되도록 이동량 산출. dx = (min_x + max_x) / 2.0 - (ix0 + ix1) / 2.0 * scale dy = (min_y + max_y) / 2.0 - (iy0 + iy1) / 2.0 * scale + if bottom_left: + dx, dy = min_x - ix0 * scale, min_y - iy0 * scale placed = [ _transform_entity(entity, f"{drawing_id}:frame:{index}", scale, dx, dy) diff --git a/B07_DesignDetail/openwebcad/src/components/ui-state.ts b/B07_DesignDetail/openwebcad/src/components/ui-state.ts index 868f1375e..faf282bf6 100644 --- a/B07_DesignDetail/openwebcad/src/components/ui-state.ts +++ b/B07_DesignDetail/openwebcad/src/components/ui-state.ts @@ -8,7 +8,8 @@ import { notifyWindow } from '../state'; export type InspectorTab = 'properties' | 'layers'; let inspectorTab: InspectorTab = 'layers'; -let inspectorCollapsed = false; +/** 특성·도면층 패널은 닫힌 채 시작 — 펼치기 단추로 연다. */ +let inspectorCollapsed = true; let quickPropertiesVisible = false; let activeRibbonTab = 'home'; let blockLibraryVisible = false; diff --git a/resources/tester/test_b07_cross_sheet_pad.py b/resources/tester/test_b07_cross_sheet_pad.py new file mode 100644 index 000000000..68e392445 --- /dev/null +++ b/resources/tester/test_b07_cross_sheet_pad.py @@ -0,0 +1,34 @@ +"""B07 횡단면도 — 단면 블록 테두리 ↔ 안쪽 그림 간격 1 mm (59-1).""" + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + CROSS_BORDER_PAD_MM, + FRAME_LAYER_ID, + build_cross_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import section_block_size +from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox + +_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) + ], +} + + +def test_frame_hugs_section_by_one_mm() -> None: + drawing = build_cross_drawing(_SOURCE, "cross_00020m", None, None, None, "") + lines = [e for e in drawing["entities"] if e.get("layerId") != FRAME_LAYER_ID] + frame = [e for e in drawing["entities"] if e.get("layerId") == FRAME_LAYER_ID] + min_x, min_y, max_x, max_y = entities_bbox(lines) + f_min_x, f_min_y, f_max_x, f_max_y = entities_bbox(frame) + assert CROSS_BORDER_PAD_MM == 1.0 + assert f_max_y - max_y == pytest.approx(1.0) + assert min_y - f_min_y == pytest.approx(1.0) + # 표 없는 블록 크기 = 테두리 크기 + width, height = section_block_size(_SOURCE, None, with_table=False) + assert height == pytest.approx(f_max_y - f_min_y) + assert width == pytest.approx(f_max_x - f_min_x) diff --git a/resources/tester/test_b07_long_sheet.py b/resources/tester/test_b07_long_sheet.py new file mode 100644 index 000000000..85002c341 --- /dev/null +++ b/resources/tester/test_b07_long_sheet.py @@ -0,0 +1,59 @@ +"""B07 종단도 — 장마다 그림 좌하단 = 작도영역 좌하단 · 고정 축척 · 넘치면 장을 더 나눔 (59-3).""" + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( + build_longitudinal_drawing, + longitudinal_chunks, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, usable_area + + +def _longitudinal(count: int, interval: float, slope: float) -> dict: + xs = [index * interval for index in range(count)] + return { + "samples": [{"valid": True, "chainage_m": x, "elevation_m": 100.0 + slope * x} for x in xs], + "stations": [{"chainage_m": x} for x in xs], + "design_profiles": [ + {"samples": [{"chainage_m": x, "elevation_m": 100.5 + slope * x} for x in xs]} + ], + } + + +def _sheets(longitudinal: dict) -> list[tuple[tuple, tuple]]: + result = [] + for chunk in longitudinal_chunks(longitudinal): + entities = build_longitudinal_drawing(longitudinal, chunk["id"], chunk)["entities"] + content = entities_bbox([e for e in entities if e.get("layerId") != FRAME_LAYER_ID]) + frame = entities_bbox([e for e in entities if e.get("layerId") == FRAME_LAYER_ID]) + result.append((content, frame)) + return result + + +@pytest.mark.parametrize( + ("count", "interval", "slope"), + [(66, 20.0, 0.05), (40, 40.0, 0.0), (40, 20.0, 0.25)], +) +def test_every_sheet_fits_and_starts_at_same_corner(count, interval, slope) -> None: + longitudinal = _longitudinal(count, interval, slope) + sheets = _sheets(longitudinal) + usable_w, usable_h = usable_area() + corners = set() + for content, frame in sheets: + assert content[2] - content[0] <= usable_w + 1e-6 + assert content[3] - content[1] <= usable_h + 1e-6 + corners.add((round(content[0] - frame[0], 3), round(content[1] - frame[1], 3))) + assert len(corners) == 1 + chunks = longitudinal_chunks(longitudinal) + # 이어지는 장은 경계 측점 하나를 나눠 갖고 끝까지 덮는다. + assert chunks[0]["start_m"] == 0.0 + assert chunks[-1]["end_m"] == (count - 1) * interval + for before, after in zip(chunks, chunks[1:]): + assert before["end_m"] == after["start_m"] + + +def test_wide_interval_splits_more() -> None: + # 40 m 간격 30측점 = 1,160 mm > 작도영역 폭 → 30개보다 적게 담는다 + chunks = longitudinal_chunks(_longitudinal(40, 40.0, 0.0)) + assert len(chunks) > 2