Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_5

This commit is contained in:
2026-10-06 17:46:45 +09:00
7 changed files with 193 additions and 71 deletions
@@ -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),
@@ -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)),
@@ -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,
@@ -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)
@@ -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;
@@ -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)
+59
View File
@@ -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