Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
# Conflicts: # B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts # B07_DesignDetail/B07_DesignDetail_Router_Support.py # B07_DesignDetail/B07_DesignDetail_Schema.py # B07_DesignDetail/B07_DesignDetail_UI_Panels.ts # resources/tester/test_311_panel_folded.py
This commit is contained in:
@@ -17,6 +17,7 @@ export interface DesignDrawingItem {
|
||||
| "plan_lidar"
|
||||
| "cross_standard"
|
||||
| "structure"
|
||||
| "development"
|
||||
| "blank";
|
||||
label: string;
|
||||
chainage_m: number | null;
|
||||
@@ -114,6 +115,7 @@ export interface DesignDrawingResponse {
|
||||
| "plan_lidar"
|
||||
| "cross_standard"
|
||||
| "structure"
|
||||
| "development"
|
||||
| "blank";
|
||||
label: string;
|
||||
drawing: CadDrawing;
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""B07 전개도 자료 · 묶음 — 종단 길이를 가진 구조물을 (종류 · 형태)마다 한 장으로(요구 241-2 · 3).
|
||||
|
||||
서버 단독(CLAUDE.md 5장 ③) · 저장 안 함 · 값을 짓지 않고 정본을 모으기만:
|
||||
관보호공 배관 유입 · 유출(`{쪽}_type == 기슭막이`) `{쪽}_revet_*`
|
||||
+ 추가 흙막이 `soil_add_{n}_*`
|
||||
BOX · 세월교 유입 · 유출 구조 == 흙막이(B08 `_guard`) + 추가 흙막이
|
||||
흙막이 · 옹벽 C군 벽(표 형상 wall) start_m ~ end_m · 높이 height_m · 추가 단 = 별도 박스
|
||||
옹벽 형태 = `wall_form`(`form` 은 형식) · 숨김 벽 종류 = 흙막이 묶음
|
||||
포장 · 쇄석 G군 `pavement_specs_from` 구간 · 폭 B = `width_m`(빈칸 = 횡단 노폭 평균)
|
||||
흙막이 길이 · 빈 높이 = B08 구조물집계표와 같은 규칙(`_revet_length` · 관경 최소 높이 · 박스 높이) ·
|
||||
시점 = 기준 − 전 · 종점 = 기준 + 후(둘 다 비면 길이 반씩) · 거리 = 종점 − 시점 · 면적 = 거리 × H(B).
|
||||
추가 흙막이 단 수 = 지형이 세운 유출 다단까지(`container_rows` · 설계가 있을 때만).
|
||||
그림 = `B07_DesignDetail_Engine_Cad_Development_Draw.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_height_m, pipe_wall_min_height_m
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import tier_extras
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingItem
|
||||
from B08_Quantity.B08_Quantity_Router_Summary import (
|
||||
FORMS,
|
||||
GUARD_SOIL,
|
||||
SOIL,
|
||||
_guard,
|
||||
_num,
|
||||
_revet_length,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||||
from common_util.common_util_structure_catalog import base_type, catalog_row
|
||||
from common_util.common_util_structure_pavement import pavement_specs_from
|
||||
from common_util.common_util_summary_item import GROUP_JOIN
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows, soil_tiers_from_designs
|
||||
|
||||
DEVELOPMENT_ID = re.compile(r"^development_([a-z]+\d?)(?:_(\d+))?$")
|
||||
#: 묶음 차례 · 이름 — 포장은 형태 없이 한 묶음
|
||||
GROUPS = {
|
||||
"pipe": "관보호공",
|
||||
"box": "BOX암거 흙막이",
|
||||
"ford": "세월교 흙막이",
|
||||
"soil": "흙막이",
|
||||
"wall": "옹벽",
|
||||
"concrete": "콘크리트포장",
|
||||
"gravel": "쇄석포설",
|
||||
}
|
||||
PAVEMENTS = {"pavement_concrete": "concrete", "gravel_surfacing": "gravel"}
|
||||
_BODY_GROUP = {"pipe": "pipe", "box_culvert": "box", "ford_bridge": "ford"}
|
||||
#: 한 장에 담는 박스 수 — 넘치면 `_2` 장(우하단 표가 작도 영역 안에 들게)
|
||||
MAX_ROWS = 30
|
||||
_EMPTY_FORM = 9 # 형태 빈칸 id 자리
|
||||
|
||||
|
||||
def station_text(chainage_m: float, interval_m: float) -> str:
|
||||
"""전개도 측점 — `No.04+08` · 소수면 `No.22+05.5`(그림 241 표기)."""
|
||||
safe = interval_m if interval_m > 0 else 20.0
|
||||
number = int((chainage_m + 1e-6) // safe)
|
||||
rest = round(chainage_m - number * safe, 1)
|
||||
if rest >= safe - 0.05:
|
||||
number, rest = number + 1, 0.0
|
||||
if abs(rest - round(rest)) < 0.05:
|
||||
return f"No.{number:02d}+{int(round(rest)):02d}"
|
||||
return f"No.{number:02d}+{rest:04.1f}"
|
||||
|
||||
|
||||
def _row(group: str, form: Any, start: float | None, end: float | None, height: Any, key: str):
|
||||
"""박스 한 줄 — 시점 · 종점 중 하나라도 없으면 길이 미입력."""
|
||||
if start is not None and end is not None and end < start:
|
||||
start, end = end, start
|
||||
return {
|
||||
"group": group,
|
||||
"form": str(form or ""),
|
||||
"start_m": start,
|
||||
"end_m": end,
|
||||
"height_m": _num(height),
|
||||
"key": key,
|
||||
}
|
||||
|
||||
|
||||
def _span(at: float, options: dict[str, Any], head: str, unit: str):
|
||||
"""기준 측점 + 전 · 후(둘 다 비면 길이 반씩) → (시점, 종점) · 길이도 없으면 (None, None)."""
|
||||
before = _num(options.get(f"{head}before{unit}"))
|
||||
after = _num(options.get(f"{head}after{unit}"))
|
||||
if before is None and after is None:
|
||||
length = _revet_length(options, head, unit)
|
||||
if not length:
|
||||
return None, None
|
||||
before = after = length / 2.0
|
||||
return at - (before or 0.0), at + (after or 0.0)
|
||||
|
||||
|
||||
def _empty_heights(type_id: str, item: dict[str, Any]) -> dict[str, float | None]:
|
||||
"""빈 높이 — 관 = 관경 최소(유입 + 0.7) · 박스 = 박스 높이 · 세월교 = 미입력(B08 같은 규칙)."""
|
||||
options = item["options"]
|
||||
if type_id == "pipe":
|
||||
diameter = _num(options.get("pipe_diameter_mm"))
|
||||
if not diameter:
|
||||
return {"inlet": None, "outlet": None, "soil_add": None}
|
||||
meters = diameter / 1000.0
|
||||
wall = pipe_wall_min_height_m(meters)
|
||||
return {"inlet": pipe_wall_min_height_m(meters, "inlet"), "outlet": wall, "soil_add": wall}
|
||||
height = _num(options.get("body_height_m")) if type_id == "box_culvert" else None
|
||||
return {"inlet": height, "outlet": height, "soil_add": height}
|
||||
|
||||
|
||||
def _body_rows(item: dict[str, Any], raw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""배관 · 박스 · 세월교 한 곳 — 유입 · 유출 흙막이 + 추가 흙막이 n."""
|
||||
type_id = item["type_id"]
|
||||
group = _BODY_GROUP[type_id]
|
||||
at = float(item["chainage_m"])
|
||||
empty = _empty_heights(type_id, item)
|
||||
diameter = _num(item["options"].get("pipe_diameter_mm"))
|
||||
if type_id == "pipe":
|
||||
sides = [side for side in ("inlet", "outlet") if raw.get(f"{side}_type") == SOIL]
|
||||
else:
|
||||
sides = [
|
||||
s for s in ("inlet", "outlet") if _guard(type_id, item["options"], s) == GUARD_SOIL
|
||||
]
|
||||
rows = []
|
||||
for side in sides:
|
||||
height = _num(raw.get(f"{side}_revet_height_m"))
|
||||
if type_id == "pipe" and diameter:
|
||||
height = pipe_wall_height_m(height, diameter / 1000.0)
|
||||
start, end = _span(at, raw, f"{side}_revet_", "_m")
|
||||
form = raw.get(f"{side}_revet_form")
|
||||
key = f"{item['복사본']}:{side}"
|
||||
rows.append(_row(group, form, start, end, empty[side] if height is None else height, key))
|
||||
for extra in container_rows(item, {}):
|
||||
link = extra["복사본"].partition(GROUP_JOIN)[2]
|
||||
if not link.startswith("gsoiladd"):
|
||||
continue
|
||||
head = f"soil_add_{link[len('gsoiladd') :]}_"
|
||||
height = _num(raw.get(f"{head}height"))
|
||||
start, end = _span(at, raw, head, "")
|
||||
height = empty["soil_add"] if height is None else height
|
||||
rows.append(_row(group, raw.get(f"{head}form"), start, end, height, extra["복사본"]))
|
||||
return rows
|
||||
|
||||
|
||||
def _wall_rows(item: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""C군 벽 — 본 단 + 추가 단(별도 박스 · 같은 구간)."""
|
||||
type_id = item["type_id"]
|
||||
options = item["options"]
|
||||
wall = base_type(type_id) == "retaining_wall"
|
||||
group = "wall" if wall else "soil"
|
||||
form = (
|
||||
options.get("wall_form")
|
||||
if wall
|
||||
else options.get("form")
|
||||
or ((catalog_row(type_id) or {}).get("shapeOpts") or {}).get("form")
|
||||
)
|
||||
start, end = _num(item.get("start_m")), _num(item.get("end_m"))
|
||||
key = item["복사본"]
|
||||
rows = [_row(group, form, start, end, options.get("height_m"), key)]
|
||||
for n, extra in enumerate(tier_extras(type_id, options), start=1):
|
||||
tier_form = extra["form"] or form
|
||||
rows.append(_row(group, tier_form, start, end, extra["height_m"], f"{key}:tier{n}"))
|
||||
return rows
|
||||
|
||||
|
||||
def _road_width(designs: dict[float, Any], start: float, end: float) -> float | None:
|
||||
"""구간 안 측점 횡단 노폭(`carriageway_width_m`) 평균 — 없으면 None."""
|
||||
widths = [
|
||||
width
|
||||
for chainage, design in (designs or {}).items()
|
||||
if start - 0.02 <= float(chainage) <= end + 0.02
|
||||
and isinstance(design, dict)
|
||||
and (width := _num(design.get("carriageway_width_m")))
|
||||
]
|
||||
return round(sum(widths) / len(widths), 2) if widths else None
|
||||
|
||||
|
||||
def _pavement_rows(item: dict[str, Any], designs: dict[float, Any]) -> list[dict[str, Any]]:
|
||||
"""콘크리트 포장 · 쇄석 포설 — 구간 × 폭 B."""
|
||||
specs = pavement_specs_from([item], {item["type_id"]})
|
||||
group = PAVEMENTS[base_type(item["type_id"])]
|
||||
if not specs:
|
||||
return [_row(group, "", None, None, None, item["복사본"])]
|
||||
start, end = specs[0]["start_m"], specs[0]["end_m"]
|
||||
width = _num(item["options"].get("width_m")) or _road_width(designs, start, end)
|
||||
if end <= start:
|
||||
start = end = None
|
||||
return [_row(group, "", start, end, width, item["복사본"])]
|
||||
|
||||
|
||||
def development_rows(
|
||||
project_root: str | Path, designs: dict[float, Any] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""설계 정본 → 전개도 박스 줄 전부(묶음 · 형태 · 시점 · 종점 · 높이(폭)).
|
||||
|
||||
`designs` = `{측점: 횡단 설계}` — 추가 흙막이 단 수 · 포장 빈 폭에 씀(없으면 저장된 것 그대로).
|
||||
"""
|
||||
rows_in = [{"chainage_m": k, "design": v} for k, v in (designs or {}).items()]
|
||||
items = fill.collect_items(
|
||||
project_root, soil_tiers=soil_tiers_from_designs(rows_in) if designs else None
|
||||
)
|
||||
raw = {
|
||||
(point.facility or "pipe", f"pipe@{round(float(point.chainage_m), 3)}"): point.options
|
||||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root)))
|
||||
}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
owner, _, link = item["복사본"].partition(GROUP_JOIN)
|
||||
if link:
|
||||
continue # 그룹 · 컨테이너 줄은 본체 줄에서 다시 셈(단 수 거름)
|
||||
type_id = item["type_id"]
|
||||
if type_id in _BODY_GROUP:
|
||||
rows += _body_rows(item, dict(raw.get((type_id, owner)) or {}))
|
||||
elif (catalog_row(type_id) or {}).get("shape") == "wall":
|
||||
rows += _wall_rows(item)
|
||||
elif base_type(type_id) in PAVEMENTS:
|
||||
rows += _pavement_rows(item, designs or {})
|
||||
return rows
|
||||
|
||||
|
||||
def _form_index(form: str) -> int:
|
||||
return FORMS.index(form) if form in FORMS else _EMPTY_FORM
|
||||
|
||||
|
||||
def development_sheets(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""(묶음 · 형태)마다 한 장 · 넘치면 `_2` — `{id, kind_id, title, group, rows, missing}`."""
|
||||
found: dict[tuple[int, int, str], list[dict[str, Any]]] = {}
|
||||
order = list(GROUPS)
|
||||
for row in rows:
|
||||
paved = row["group"] in ("concrete", "gravel")
|
||||
form = "" if paved else row["form"]
|
||||
key = (order.index(row["group"]), _form_index(form) if not paved else 0, form)
|
||||
found.setdefault(key, []).append(row)
|
||||
sheets = []
|
||||
for (group_at, form_at, form), members in sorted(found.items()):
|
||||
group = order[group_at]
|
||||
paved = group in ("concrete", "gravel")
|
||||
kind_id = f"development_{group}" if paved else f"development_{group}{form_at}"
|
||||
title = (
|
||||
f"{GROUPS[group]} 전개도"
|
||||
if paved
|
||||
else f"{GROUPS[group]} {form or '형태 미입력'} 전개도"
|
||||
)
|
||||
drawn = sorted(
|
||||
(r for r in members if r["start_m"] is not None), key=lambda r: (r["start_m"], r["key"])
|
||||
)
|
||||
missing = len(members) - len(drawn)
|
||||
chunks = [drawn[i : i + MAX_ROWS] for i in range(0, len(drawn), MAX_ROWS)] or [[]]
|
||||
for index, chunk in enumerate(chunks, start=1):
|
||||
sheets.append(
|
||||
{
|
||||
"id": kind_id if index == 1 else f"{kind_id}_{index}",
|
||||
"kind_id": kind_id,
|
||||
"title": title if index == 1 else f"{title} ({index})",
|
||||
"group": group,
|
||||
"form": form,
|
||||
"rows": chunk,
|
||||
"missing": missing if index == 1 else 0,
|
||||
}
|
||||
)
|
||||
return sheets
|
||||
|
||||
|
||||
def table_rows(rows: list[dict[str, Any]], interval_m: float) -> list[dict[str, Any]]:
|
||||
"""우하단 표 줄 — 측점 범위 · 거리 · 높이(폭) · 면적(높이 빈칸이면 빈칸)."""
|
||||
out = []
|
||||
for row in rows:
|
||||
distance = round(row["end_m"] - row["start_m"], 3)
|
||||
height = row["height_m"]
|
||||
out.append(
|
||||
{
|
||||
"station": f"{station_text(row['start_m'], interval_m)}~"
|
||||
f"{station_text(row['end_m'], interval_m)}",
|
||||
"distance": distance,
|
||||
"height": height,
|
||||
"area": None if height is None else round(distance * height, 3),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def development_list(
|
||||
project_root: Path, manifest_drawings: dict[str, Any]
|
||||
) -> list[DesignDrawingItem]:
|
||||
"""좌측 「전개도」 카드 단추 — 장마다 한 줄 · 하위 묶음(plan_kind) = 구조물 종류."""
|
||||
return [
|
||||
DesignDrawingItem(
|
||||
id=sheet["id"],
|
||||
kind="development",
|
||||
label=sheet["title"],
|
||||
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
|
||||
plan_kind=f"development_{sheet['group']}",
|
||||
plan_kind_name=GROUPS[sheet["group"]],
|
||||
)
|
||||
for sheet in development_sheets(development_rows(project_root))
|
||||
]
|
||||
|
||||
|
||||
def read_development(
|
||||
project_root: Path, longitudinal_path: Path, drawing_id: str, designs: Any
|
||||
) -> tuple[str, str, dict[str, Any], bool, None]:
|
||||
"""전개도 한 장 — 목록 뒤 설계로 단 수가 줄어 장이 사라졌으면 빈 장 + 안내."""
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development_Draw import (
|
||||
build_development_drawing,
|
||||
)
|
||||
|
||||
designs = designs if isinstance(designs, dict) else {}
|
||||
sheets = development_sheets(development_rows(project_root, designs))
|
||||
sheet = next((s for s in sheets if s["id"] == drawing_id), None)
|
||||
if sheet is None:
|
||||
match = DEVELOPMENT_ID.fullmatch(drawing_id)
|
||||
if not match:
|
||||
raise ValueError("올바르지 않은 도면 ID입니다.")
|
||||
base = next((s for s in sheets if s["kind_id"] == f"development_{match.group(1)}"), None)
|
||||
sheet = {**(base or {"title": "전개도", "group": "soil", "form": ""}), "rows": []}
|
||||
sheet["missing"] = 0
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or []) or 20.0
|
||||
drawing = build_development_drawing(drawing_id, sheet, interval)
|
||||
return "development", sheet["title"], drawing, False, None
|
||||
@@ -0,0 +1,440 @@
|
||||
"""B07 전개도 그림 — 한 장 = 박스 줄바꿈 배치 · 형태 무늬 · 양 끝 측점 · 우하단 표 · 도각.
|
||||
|
||||
그림 241(콘크리트포장 · 전석메쌓기 전개도) 꼴:
|
||||
박스 = 거리(종점 − 시점) × H(포장 B) · 좌우 끝 = 시점 · 종점 측점(붉은 지시선 + 기울인 글)
|
||||
· 끝 옆 「H=2.0」(포장 「B=3.5」) · 박스 아래 「L=6.0m」
|
||||
· 우하단 표(측점 · 거리 · 높이/폭 · 면적 · 계)
|
||||
무늬 = 기본 도형(PolyLine · Circle · Line)으로 직접 — CAD Hatch 는 DXF 내보내면 채움이 사라짐.
|
||||
흔들림 = 도면 id · 박스 열쇠 씨앗(같은 자료 = 같은 그림 · 엔티티 id 도 uuid5 결정).
|
||||
좌표 = 종이 mm · 가로 축척 자동(한 장에 드는 가장 큰 그림) · 세로는 가장 높은 박스가 12~40 mm.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DRAWING_FORMAT,
|
||||
FRAME_LAYER_ID,
|
||||
TABLE_LABEL_COLOR,
|
||||
TABLE_VALUE_COLOR,
|
||||
_circle_entity,
|
||||
_layer,
|
||||
_line_entity,
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
table_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import station_text, table_rows
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
drawing_area,
|
||||
frame_entities,
|
||||
scale_fields,
|
||||
)
|
||||
|
||||
BOX_LAYER_ID = "b07-dev-box"
|
||||
FILL_LAYER_ID = "b07-dev-fill"
|
||||
LABEL_LAYER_ID = "b07-dev-label"
|
||||
TABLE_LAYER_ID = "b07-dev-table"
|
||||
BOX_COLOR = "#f5f7fa"
|
||||
FILL_COLOR = "#aab4c0"
|
||||
LEADER_COLOR = "#ff4d4d"
|
||||
TABLE_COLOR = "#e03131"
|
||||
#: 가로 축척 후보(분모) — 작은 것부터 대어 보고 처음 드는 것
|
||||
SCALES = (50, 100, 200, 300, 500, 600, 1000, 1200, 2000, 2500, 3000, 5000)
|
||||
_MARGIN = 10.0
|
||||
_TITLE_H = 26.0 # 제목 자리
|
||||
_LEAD = 18.0 # 박스 위 지시선 · 측점 글 자리
|
||||
_BELOW = 9.0 # 박스 아래 L= 자리
|
||||
_GAP = 16.0 # 박스 사이(오른 끝 기울인 글 자리)
|
||||
_MIN_H, _MAX_H = 12.0, 40.0 # 가장 높은 박스 세로 mm
|
||||
_EMPTY_H = 0.6 # 높이 빈칸 박스 = 가장 높은 박스의 몫
|
||||
_FONT, _TITLE_FONT = 2.5, 7.0
|
||||
_TABLE_FONT = 3.2 # 표는 그림 241 처럼 크게(작도 영역 폭의 약 1/4)
|
||||
_ROW_H = 9.0
|
||||
_COLUMNS = (80.0, 32.0, 32.0, 40.0)
|
||||
_SLANT = (0.5, math.sqrt(3) / 2) # 60°
|
||||
|
||||
|
||||
def _fmt(value: float | None) -> str:
|
||||
return "" if value is None else f"{value:,.1f}"
|
||||
|
||||
|
||||
def _size(value: float | None) -> str:
|
||||
"""높이 · 폭 — 소수 둘째 자리까지(3.55 · 3.5 · 2.0) · 면적이 쓴 값 그대로 보이게."""
|
||||
if value is None:
|
||||
return ""
|
||||
text = f"{value:.2f}".rstrip("0")
|
||||
return f"{text}0" if text.endswith(".") else text
|
||||
|
||||
|
||||
# ── 무늬 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _stone(rng: random.Random, cx: float, cy: float, r: float) -> list[tuple[float, float]]:
|
||||
"""흔든 7각 돌 하나(닫힌 점열)."""
|
||||
turn = rng.uniform(0, math.tau)
|
||||
points = [
|
||||
(
|
||||
cx + r * rng.uniform(0.7, 1.0) * math.cos(turn + i * math.tau / 7),
|
||||
cy + r * rng.uniform(0.7, 1.0) * math.sin(turn + i * math.tau / 7),
|
||||
)
|
||||
for i in range(7)
|
||||
]
|
||||
return [*points, points[0]]
|
||||
|
||||
|
||||
def _stones(seed, rng, box, rows, cap=500):
|
||||
"""박스 안 엇갈린 격자에 흔든 돌 — 높이에 `rows` 줄(크기 = 높이 몫) · 박스를 안 넘음."""
|
||||
x0, y0, x1, y1 = box
|
||||
r = min((y1 - y0) / (rows * 2.3), (y1 - y0) / 2 - 0.3, (x1 - x0) / 2 - 0.3)
|
||||
if r < 0.3:
|
||||
return []
|
||||
step = 2.3 * r
|
||||
out = []
|
||||
y, row = y0 + 0.3 + r, 0
|
||||
while y <= y1 - 0.3 - r + 1e-9 and len(out) < cap:
|
||||
x = x0 + 0.3 + r + (step / 2 if row % 2 else 0.0)
|
||||
while x <= x1 - 0.3 - r + 1e-9 and len(out) < cap:
|
||||
room = (x - x0 - 0.3 - r, x1 - 0.3 - r - x) # 왼 · 오 여유 — 돌이 박스를 안 넘게
|
||||
jx = max(-room[0], min(rng.uniform(-0.15, 0.15) * step, room[1]))
|
||||
line = polyline_entity(
|
||||
seed, _stone(rng, x + jx, y, r), FILL_LAYER_ID, FILL_COLOR, f":s{len(out)}"
|
||||
)
|
||||
if line:
|
||||
out.append(line)
|
||||
x += step
|
||||
y += step * 0.87
|
||||
row += 1
|
||||
return out
|
||||
|
||||
|
||||
def _dots(seed, rng, box, per_mm2, radius, cap):
|
||||
x0, y0, x1, y1 = box
|
||||
count = min(cap, int((x1 - x0) * (y1 - y0) * per_mm2))
|
||||
return [
|
||||
_circle_entity(
|
||||
f"{seed}:d{i}",
|
||||
(rng.uniform(x0 + radius, x1 - radius), rng.uniform(y0 + radius, y1 - radius)),
|
||||
radius,
|
||||
FILL_LAYER_ID,
|
||||
FILL_COLOR,
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
def _grid(seed, box, step):
|
||||
x0, y0, x1, y1 = box
|
||||
out = []
|
||||
x = x0 + step
|
||||
while x < x1 - 0.1:
|
||||
out.append(
|
||||
_line_entity(f"{seed}:gx{len(out)}", (x, y0), (x, y1), FILL_LAYER_ID, FILL_COLOR)
|
||||
)
|
||||
x += step
|
||||
y = y0 + step
|
||||
while y < y1 - 0.1:
|
||||
out.append(
|
||||
_line_entity(f"{seed}:gy{len(out)}", (x0, y), (x1, y), FILL_LAYER_ID, FILL_COLOR)
|
||||
)
|
||||
y += step
|
||||
return out
|
||||
|
||||
|
||||
def _logs(seed, box):
|
||||
"""통나무 · 목재틀 = 가운데 원 한 줄."""
|
||||
x0, y0, x1, y1 = box
|
||||
r = min((y1 - y0) / 2 - 0.3, 4.0)
|
||||
if r < 0.3:
|
||||
return []
|
||||
count = max(int((x1 - x0 - 0.4) // (2 * r + 0.4)), 0)
|
||||
cy = (y0 + y1) / 2
|
||||
return [
|
||||
_circle_entity(
|
||||
f"{seed}:o{i}", (x0 + 0.4 + r + i * (2 * r + 0.4), cy), r, FILL_LAYER_ID, FILL_COLOR
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
def _stakes(seed, box, step=2.5):
|
||||
"""바자 = 세운 말뚝 줄."""
|
||||
x0, y0, x1, y1 = box
|
||||
count = max(int((x1 - x0) // step), 0)
|
||||
return [
|
||||
_line_entity(
|
||||
f"{seed}:k{i}",
|
||||
(x0 + (i + 0.5) * step, y0),
|
||||
(x0 + (i + 0.5) * step, y1),
|
||||
FILL_LAYER_ID,
|
||||
FILL_COLOR,
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
def fill_entities(seed: str, form: str, group: str, box: tuple[float, float, float, float]):
|
||||
"""형태 → 무늬(찰 작은 돌 · 메 큰 돌 · 콘크리트 · 쇄석 점 · 돌망태 격자+돌 · 통나무 원)."""
|
||||
rng = random.Random(seed)
|
||||
if group == "gravel":
|
||||
return _dots(seed, rng, box, 0.025, 0.9, 900)
|
||||
if group == "concrete" or form == "콘크리트":
|
||||
return _dots(seed, rng, box, 0.045, 0.6, 1200)
|
||||
if form == "돌쌓기(찰)":
|
||||
return _stones(seed, rng, box, 5)
|
||||
if form == "돌쌓기(메)":
|
||||
return _stones(seed, rng, box, 2.5)
|
||||
if form == "돌망태":
|
||||
cells = max(round((box[3] - box[1]) / 12.0), 1) # 망태 한 단 ≈ 12 mm
|
||||
return _grid(seed, box, (box[3] - box[1]) / cells) + _stones(seed, rng, box, 3 * cells)
|
||||
if form == "통나무·목재틀":
|
||||
return _logs(seed, box)
|
||||
if form == "바자":
|
||||
return _stakes(seed, box)
|
||||
return []
|
||||
|
||||
|
||||
# ── 배치 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _vertical(rows: list[dict[str, Any]], mm_h: float) -> float:
|
||||
"""세로 mm/m — 가로와 같게 두되 가장 높은 박스가 12~40 mm 안에 들게."""
|
||||
tallest = max((r["height_m"] for r in rows if r["height_m"]), default=None)
|
||||
if not tallest:
|
||||
return _MIN_H
|
||||
return min(max(mm_h, _MIN_H / tallest), _MAX_H / tallest)
|
||||
|
||||
|
||||
def layout(rows, area, table_box, mm_h, strict=True):
|
||||
"""박스 줄바꿈 배치 → [(row, x0, y0, x1, y1)] · 영역을 넘치면 None(strict)."""
|
||||
ax0, ay0, ax1, ay1 = area
|
||||
v = _vertical(rows, mm_h)
|
||||
tallest = max((r["height_m"] or 0.0 for r in rows), default=0.0)
|
||||
empty_h = (tallest * v if tallest else _MIN_H) * _EMPTY_H
|
||||
left, top = ax0 + _MARGIN, ay1 - _TITLE_H
|
||||
placed, line, x, line_h = [], [], left, 0.0
|
||||
|
||||
def right_limit(height: float) -> float:
|
||||
bottom = top - _LEAD - height - _BELOW
|
||||
return table_box[0] - _GAP if bottom < table_box[3] + 4.0 else ax1 - _MARGIN
|
||||
|
||||
for row in rows:
|
||||
w = max((row["end_m"] - row["start_m"]) * mm_h, 2.0)
|
||||
h = row["height_m"] * v if row["height_m"] else empty_h
|
||||
if line and x + w > right_limit(max(line_h, h)):
|
||||
top -= _LEAD + line_h + _BELOW
|
||||
placed += line
|
||||
line, x, line_h = [], left, 0.0
|
||||
if strict and (
|
||||
x + w > right_limit(max(line_h, h)) or top - _LEAD - h - _BELOW < ay0 + _MARGIN
|
||||
):
|
||||
return None
|
||||
line.append((row, x, top - _LEAD - h, x + w, top - _LEAD))
|
||||
x += w + _GAP
|
||||
line_h = max(line_h, h)
|
||||
return placed + line
|
||||
|
||||
|
||||
def _choose(rows, area, table_box):
|
||||
"""처음 드는 축척 — 끝까지 안 들면 가장 작은 축척으로 그대로(도각이 넘침 경고)."""
|
||||
for denominator in SCALES:
|
||||
placed = layout(rows, area, table_box, 1000.0 / denominator)
|
||||
if placed is not None:
|
||||
return denominator, placed
|
||||
denominator = SCALES[-1]
|
||||
return denominator, layout(rows, area, table_box, 1000.0 / denominator, strict=False)
|
||||
|
||||
|
||||
# ── 글 · 표 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _box_entities(drawing_id, index, row, box, label, station):
|
||||
x0, y0, x1, y1 = box
|
||||
seed = f"{drawing_id}:b{index}"
|
||||
out = [
|
||||
polyline_entity(
|
||||
drawing_id,
|
||||
[(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)],
|
||||
BOX_LAYER_ID,
|
||||
BOX_COLOR,
|
||||
f":b{index}",
|
||||
)
|
||||
]
|
||||
out += fill_entities(f"{seed}:{row['key']}", row["form"], row["group"], (x0, y0, x1, y1))
|
||||
height = "미입력" if row["height_m"] is None else _size(row["height_m"])
|
||||
for end, x, chainage in (("s", x0, row["start_m"]), ("e", x1, row["end_m"])):
|
||||
out.append(
|
||||
_line_entity(
|
||||
f"{seed}:{end}:lead", (x, y1), (x, y1 + _LEAD - 2.0), LABEL_LAYER_ID, LEADER_COLOR
|
||||
)
|
||||
)
|
||||
out.append(
|
||||
_text_entity(
|
||||
f"{seed}:{end}:no",
|
||||
station(chainage),
|
||||
x + 0.8,
|
||||
y1 + 1.2,
|
||||
LABEL_LAYER_ID,
|
||||
_FONT,
|
||||
TABLE_LABEL_COLOR,
|
||||
"left",
|
||||
_SLANT,
|
||||
)
|
||||
)
|
||||
out.append(
|
||||
_text_entity(
|
||||
f"{seed}:{end}:h",
|
||||
f"{label}={height}",
|
||||
x + 3.6,
|
||||
y1 + 1.2,
|
||||
LABEL_LAYER_ID,
|
||||
_FONT,
|
||||
TABLE_VALUE_COLOR,
|
||||
"left",
|
||||
_SLANT,
|
||||
)
|
||||
)
|
||||
length = row["end_m"] - row["start_m"]
|
||||
out.append(
|
||||
_text_entity(
|
||||
f"{seed}:len",
|
||||
f"L={length:.1f}m",
|
||||
(x0 + x1) / 2,
|
||||
y0 - 5.0,
|
||||
LABEL_LAYER_ID,
|
||||
_FONT,
|
||||
TABLE_LABEL_COLOR,
|
||||
)
|
||||
)
|
||||
return [entity for entity in out if entity]
|
||||
|
||||
|
||||
def _table(drawing_id, lines, label, origin):
|
||||
"""측점(NO) · 거리(M) · 높이/폭(M) · 면적(㎡) · 계."""
|
||||
|
||||
def cell(text, align="center", color=TABLE_LABEL_COLOR):
|
||||
return {"text": text, "align": align, "color": color}
|
||||
|
||||
head = "폭" if label == "B" else "높이"
|
||||
cells = [
|
||||
[cell("측 점"), cell("거 리"), cell(head), cell("면 적")],
|
||||
[cell("(NO)"), cell("(M)"), cell("(M)"), cell("(㎡)")],
|
||||
]
|
||||
for item in lines:
|
||||
cells.append(
|
||||
[
|
||||
cell(item["station"]),
|
||||
cell(_fmt(item["distance"]), "right", TABLE_VALUE_COLOR),
|
||||
cell(_size(item["height"]), "right", TABLE_VALUE_COLOR),
|
||||
cell(_fmt(item["area"]), "right", TABLE_VALUE_COLOR),
|
||||
]
|
||||
)
|
||||
areas = [item["area"] for item in lines if item["area"] is not None]
|
||||
cells.append(
|
||||
[
|
||||
cell("계"),
|
||||
cell(_fmt(sum(item["distance"] for item in lines)), "right", TABLE_VALUE_COLOR),
|
||||
cell(""),
|
||||
cell(_fmt(sum(areas)) if areas else "", "right", TABLE_VALUE_COLOR),
|
||||
]
|
||||
)
|
||||
return table_entity(
|
||||
f"{drawing_id}:table",
|
||||
origin,
|
||||
list(_COLUMNS),
|
||||
[_ROW_H] * len(cells),
|
||||
cells,
|
||||
TABLE_LAYER_ID,
|
||||
TABLE_COLOR,
|
||||
_TABLE_FONT,
|
||||
TABLE_LABEL_COLOR,
|
||||
)
|
||||
|
||||
|
||||
def build_development_drawing(
|
||||
drawing_id: str, sheet: dict[str, Any], interval_m: float
|
||||
) -> dict[str, Any]:
|
||||
"""전개도 한 장 → {format, entities, layers} — 작도 영역에 그대로 놓고 도각을 두름."""
|
||||
area = drawing_area()
|
||||
ax0, ay0, ax1, ay1 = area
|
||||
rows = sheet["rows"]
|
||||
lines = table_rows(rows, interval_m)
|
||||
label = "B" if sheet["group"] in ("concrete", "gravel") else "H"
|
||||
table_w, table_h = sum(_COLUMNS), _ROW_H * (len(lines) + 3)
|
||||
table_box = (ax1 - _MARGIN - table_w, ay0 + _MARGIN, ax1 - _MARGIN, ay0 + _MARGIN + table_h)
|
||||
denominator, placed = _choose(rows, area, table_box)
|
||||
|
||||
def station(chainage: float) -> str:
|
||||
return station_text(chainage, interval_m)
|
||||
|
||||
entities: list[dict[str, Any]] = [
|
||||
_text_entity(
|
||||
f"{drawing_id}:title",
|
||||
sheet["title"],
|
||||
(ax0 + ax1) / 2,
|
||||
ay1 - 14.0,
|
||||
LABEL_LAYER_ID,
|
||||
_TITLE_FONT,
|
||||
TABLE_LABEL_COLOR,
|
||||
),
|
||||
_text_entity(
|
||||
f"{drawing_id}:scale",
|
||||
f"S=1:{denominator:,}",
|
||||
ax1 - _MARGIN,
|
||||
ay1 - 14.0,
|
||||
LABEL_LAYER_ID,
|
||||
_FONT,
|
||||
TABLE_LABEL_COLOR,
|
||||
"right",
|
||||
),
|
||||
]
|
||||
for index, (row, *box) in enumerate(placed or []):
|
||||
entities += _box_entities(drawing_id, index, row, tuple(box), label, station)
|
||||
entities.append(_table(drawing_id, lines, label, (table_box[0], table_box[3])))
|
||||
if sheet.get("missing"):
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:missing",
|
||||
f"길이 미입력 {sheet['missing']}곳 — 그리지 않음",
|
||||
table_box[2],
|
||||
table_box[3] + 4.0,
|
||||
LABEL_LAYER_ID,
|
||||
_FONT,
|
||||
TABLE_VALUE_COLOR,
|
||||
"right",
|
||||
)
|
||||
)
|
||||
if not rows and not sheet.get("missing"):
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:empty",
|
||||
"그릴 구조물이 없습니다 — 목록을 새로 고침",
|
||||
(ax0 + ax1) / 2,
|
||||
(ay0 + ay1) / 2,
|
||||
LABEL_LAYER_ID,
|
||||
_FONT * 2,
|
||||
TABLE_VALUE_COLOR,
|
||||
)
|
||||
)
|
||||
title = sheet["title"]
|
||||
entities += frame_entities(
|
||||
drawing_id,
|
||||
area,
|
||||
fit=False,
|
||||
bottom_left=True,
|
||||
fields={"도면명": title, **scale_fields(("", denominator))},
|
||||
)
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
"entities": entities,
|
||||
"layers": [
|
||||
_layer(BOX_LAYER_ID, "전개도 박스"),
|
||||
_layer(FILL_LAYER_ID, "재질 무늬"),
|
||||
_layer(LABEL_LAYER_ID, "측점 · 치수"),
|
||||
_layer(TABLE_LAYER_ID, "전개도 표"),
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
],
|
||||
}
|
||||
@@ -27,6 +27,7 @@ from B06_Section.B06_Section_Repository import (
|
||||
get_project_standard_cross_section,
|
||||
merge_cross_section_design_by_round,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import DEVELOPMENT_ID
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
CROSS_SHEET_ID,
|
||||
)
|
||||
@@ -344,6 +345,9 @@ async def get_design_drawing(
|
||||
if row
|
||||
else None
|
||||
)
|
||||
elif DEVELOPMENT_ID.fullmatch(drawing_id):
|
||||
# 전개도 — 추가 흙막이 단 수 · 포장 빈 폭을 측점별 설계에서 읽음(377)
|
||||
source_design = await _designs_by_chainage(route_id)
|
||||
elif PLAN_KIND_ID.fullmatch(drawing_id):
|
||||
# 계획평면도 — 정보 조각을 서버가 계산 · 파일로 두고 그 종류가 고른 것만 얹는다.
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
|
||||
@@ -34,6 +34,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
|
||||
station_no_label as station_no_label_upper,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import (
|
||||
DEVELOPMENT_ID,
|
||||
development_list,
|
||||
read_development,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
@@ -249,6 +254,8 @@ def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDra
|
||||
)
|
||||
# 구조물 도면(376) — 맨 뒤에 덧붙임(앞 도면번호 안 밀림) · 조합마다 한 장 · 더한장은 장마다.
|
||||
drawings.extend(structure_items(project_root, manifest_drawings))
|
||||
# 전개도(요구 241 · 377) — 맨 뒤 · (구조물 종류 · 형태)마다 장
|
||||
drawings += development_list(project_root, manifest_drawings)
|
||||
_store_drawing_numbers(project_root, drawings)
|
||||
return drawings
|
||||
|
||||
@@ -488,6 +495,8 @@ def _read_drawing(
|
||||
kind = "plan"
|
||||
elif drawing_id.startswith(STRUCTURE_PREFIX):
|
||||
kind = "structure"
|
||||
elif DEVELOPMENT_ID.fullmatch(drawing_id):
|
||||
kind = "development"
|
||||
else:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
label = str(manifest_entry.get("label") or drawing_id)
|
||||
@@ -539,6 +548,10 @@ def _read_drawing(
|
||||
None,
|
||||
)
|
||||
|
||||
if DEVELOPMENT_ID.fullmatch(drawing_id):
|
||||
# stored_design = 노선 전체 측점별 설계(추가 흙막이 단 수 · 포장 빈 폭)
|
||||
return read_development(project_root, longitudinal_path, drawing_id, stored_design)
|
||||
|
||||
if PLAN_KIND_ID.fullmatch(drawing_id):
|
||||
# stored_design = plan_kind_source()가 계산 · 저장한 조각 중 그 종류가 고른 것.
|
||||
if not isinstance(stored_design, dict):
|
||||
|
||||
@@ -22,6 +22,7 @@ class DesignDrawingItem(BaseModel):
|
||||
"cross_standard",
|
||||
# 구조물 도면(376) — B05 · B06 배치에 연결된 구조물 조합 도면.
|
||||
"structure",
|
||||
"development", # 전개도(요구 241 · 377)
|
||||
"blank",
|
||||
]
|
||||
label: str
|
||||
@@ -64,6 +65,7 @@ class DesignDrawingResponse(BaseModel):
|
||||
"cross_standard",
|
||||
# 구조물 도면(376) — B05 · B06 배치에 연결된 구조물 조합 도면.
|
||||
"structure",
|
||||
"development", # 전개도(요구 241 · 377)
|
||||
"blank",
|
||||
]
|
||||
label: str
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_UI_Development.ts
|
||||
* 좌측 「전개도」 카드(요구 241 · PLAN 377) — 늘 카드 · 구조물 종류마다 하위 묶음(이름 줄 + 형태 단추).
|
||||
*
|
||||
* 묶음 = `groupPlanSheets`(plan_kind = 구조물 종류 · plan_kind_name = 이름) 재사용 · 모양 = 평면도 묶음 class.
|
||||
* 단추 글 = 장 이름에서 종류 이름 · 「전개도」를 뺀 형태(전체 이름은 툴팁).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import type { DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
|
||||
import { groupPlanSheets } from "./B07_DesignDetail_UI_PlanKinds";
|
||||
|
||||
/** 「관보호공 돌쌓기(찰) 전개도 (2)」 → 「돌쌓기(찰) (2)」 · 포장 「콘크리트포장 전개도」 → 「콘크리트포장」. */
|
||||
function shortLabel(label: string, kindName: string): string {
|
||||
const rest = label.replace(" 전개도", "");
|
||||
return rest.startsWith(`${kindName} `) ? rest.slice(kindName.length + 1) : rest;
|
||||
}
|
||||
|
||||
export function buildDevelopmentSection(
|
||||
items: DesignDrawingItem[],
|
||||
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
||||
collapsed = false,
|
||||
): HTMLElement {
|
||||
const section = createPanelCard(`전개도 ${items.length}`, collapsed);
|
||||
section.classList.add("b07-drawing-group");
|
||||
section.dataset.kind = "development";
|
||||
if (items.length === 0) {
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "b07-plan-kind__name";
|
||||
empty.textContent = "종단 길이를 가진 구조물이 없습니다.";
|
||||
section.append(empty);
|
||||
return section;
|
||||
}
|
||||
for (const { kindId, name, sheets } of groupPlanSheets(items)) {
|
||||
const block = document.createElement("div");
|
||||
block.className = "b07-plan-kind";
|
||||
block.dataset.planKind = kindId;
|
||||
const title = document.createElement("span");
|
||||
title.className = "b07-plan-kind__name";
|
||||
title.textContent = name;
|
||||
block.append(title);
|
||||
for (const { drawing } of sheets) {
|
||||
const button = makeButton(drawing, shortLabel(drawing.label, name));
|
||||
button.title = drawing.label;
|
||||
block.append(button);
|
||||
}
|
||||
section.append(block);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
|
||||
import { buildDevelopmentSection } from "./B07_DesignDetail_UI_Development";
|
||||
import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds";
|
||||
import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage";
|
||||
|
||||
@@ -39,6 +40,8 @@ export const DRAWING_GROUPS: readonly {
|
||||
{ label: "토적도(유토곡선)", kind: "mass_haul" },
|
||||
// 구조물 도면(376) — B05 · B06 배치에 연결된 구조물 조합마다 한 장(더한장은 장마다) · 늘 묶음.
|
||||
{ label: "구조물 도면", kind: "structure" },
|
||||
// 전개도(요구 241 · 377) — 늘 카드 · 구조물 종류마다 하위 묶음
|
||||
{ label: "전개도", kind: "development" },
|
||||
];
|
||||
|
||||
/** 311 기본 접힘 묶음 — 그 밖 묶음(토적도 등)은 기본 펼침. */
|
||||
@@ -122,6 +125,11 @@ export function buildDrawingSidePanel(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (group.kind === "development") {
|
||||
const sheets = drawings.filter((item) => item.kind === "development");
|
||||
panel.append(buildDevelopmentSection(sheets, drawingButton, isFolded("development")));
|
||||
continue;
|
||||
}
|
||||
const items = group.kind
|
||||
? drawings.filter((item) => item.kind === group.kind)
|
||||
: group.idPrefix
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B07 전개도(요구 241-2 · 3 · PLAN 377-1) — 측점 표기 · 묶음 · 거리 · 면적 · 계 · 그림 · DXF.
|
||||
|
||||
견본(그림 241 콘크리트포장): No.22+00~No.26+05 = 85 m × B 3.5 = 297.5 ㎡ (측점 간격 20 m).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path
|
||||
from B07_DesignDetail import B07_DesignDetail_Engine_Cad_Development as dev
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development_Draw import (
|
||||
FILL_LAYER_ID,
|
||||
build_development_drawing,
|
||||
fill_entities,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import drawing_to_dxf
|
||||
|
||||
POINTS = [
|
||||
{ # 관 Φ800 — 유입 찰 H2.0(전 3 · 후 3) · 유출 메 높이 빈칸(관경 최소 1.3) · 추가 흙막이 1 · 2
|
||||
"chainage_m": 88.0,
|
||||
"options": {
|
||||
"pipe_diameter_mm": 800,
|
||||
"inlet_type": "기슭막이",
|
||||
"inlet_revet_form": "돌쌓기(찰)",
|
||||
"inlet_revet_height_m": 2.0,
|
||||
"inlet_revet_before_m": 3,
|
||||
"inlet_revet_after_m": 3,
|
||||
"outlet_type": "기슭막이",
|
||||
"outlet_revet_form": "돌쌓기(메)",
|
||||
"outlet_revet_length_m": 8, # 전 · 후 빈칸 → 반씩
|
||||
"soil_add_1_form": "돌쌓기(메)",
|
||||
"soil_add_1_height": 1.5,
|
||||
"soil_add_1_before": 2,
|
||||
"soil_add_1_after": 4.5,
|
||||
"soil_add_2_form": "돌쌓기(메)",
|
||||
"soil_add_2_height": 1.5,
|
||||
},
|
||||
},
|
||||
{ # 박스 — 유출 흙막이(높이 빈칸 = 박스 높이 2.5) · 유입 날개벽
|
||||
"chainage_m": 300.0,
|
||||
"facility": "box_culvert",
|
||||
"options": {
|
||||
"body_height_m": 2.5,
|
||||
"wing_in": "날개벽",
|
||||
"wing_out": "흙막이",
|
||||
"outlet_revet_form": "돌쌓기(메)",
|
||||
"outlet_revet_before_m": 3,
|
||||
"outlet_revet_after_m": 3,
|
||||
},
|
||||
},
|
||||
]
|
||||
STRUCTURES = [
|
||||
{ # 콘크리트 포장 No.22+00 ~ No.26+05 · 폭 3.5
|
||||
"type_id": "pavement_concrete",
|
||||
"placement": "interval",
|
||||
"start_m": 440.0,
|
||||
"end_m": 525.0,
|
||||
"options": {"width_m": 3.5},
|
||||
},
|
||||
{ # 폭 빈칸 = 횡단 노폭 구간 평균
|
||||
"type_id": "pavement_concrete",
|
||||
"placement": "interval",
|
||||
"start_m": 720.0,
|
||||
"end_m": 760.0,
|
||||
"options": {},
|
||||
},
|
||||
{ # 흙막이 메 H2.0 + 추가 단 하나(찰 H1.0 = 별도 박스 · 찰 장)
|
||||
"type_id": "soil_guard",
|
||||
"placement": "interval",
|
||||
"start_m": 80.0,
|
||||
"end_m": 88.0,
|
||||
"options": {
|
||||
"form": "돌쌓기(메)",
|
||||
"height_m": 2.0,
|
||||
"tier_1_form": "돌쌓기(찰)",
|
||||
"tier_1_height": 1.0,
|
||||
},
|
||||
},
|
||||
{ # 숨김 벽 종류(돌쌓기(메)) = 흙막이 묶음 · 높이 빈칸
|
||||
"type_id": "masonry_dry",
|
||||
"placement": "interval",
|
||||
"start_m": 100.0,
|
||||
"end_m": 110.0,
|
||||
"options": {"height_m": None},
|
||||
},
|
||||
{ # 옹벽 — 형태 = wall_form(form 은 형식)
|
||||
"type_id": "retaining_wall",
|
||||
"placement": "interval",
|
||||
"start_m": 200.0,
|
||||
"end_m": 210.0,
|
||||
"options": {"form": "중력식", "wall_form": "콘크리트", "height_m": 3.0},
|
||||
},
|
||||
]
|
||||
DESIGNS = {
|
||||
88.0: {"extra_walls": [{"side": "outlet"}]}, # 유출 다단 1 → 추가 흙막이 2 는 안 섬
|
||||
720.0: {"carriageway_width_m": 4.0},
|
||||
740.0: {"carriageway_width_m": 5.0},
|
||||
760.0: {"carriageway_width_m": 4.5},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path: Path) -> Path:
|
||||
found = tmp_path / "proj"
|
||||
edits = found / "B04_PreProcess/drainage/edits"
|
||||
edits.mkdir(parents=True)
|
||||
(edits / "pipe_points.json").write_text(
|
||||
json.dumps({"points": [{**p, "source": "user"} for p in POINTS]}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
path = Path(structures_file_path(str(found)))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def _sheet(sheets, sheet_id):
|
||||
return next(s for s in sheets if s["id"] == sheet_id)
|
||||
|
||||
|
||||
def test_측점_표기():
|
||||
assert dev.station_text(440.0, 20) == "No.22+00"
|
||||
assert dev.station_text(525.0, 20) == "No.26+05"
|
||||
assert dev.station_text(88.0, 20) == "No.04+08"
|
||||
assert dev.station_text(445.5, 20) == "No.22+05.5"
|
||||
assert dev.station_text(459.98, 20) == "No.23+00"
|
||||
|
||||
|
||||
def test_그림_예_거리_면적_계():
|
||||
rows = [
|
||||
dev._row("concrete", "", 440.0, 525.0, 3.5, "a"),
|
||||
dev._row("concrete", "", 720.0, 860.0, 3.5, "b"),
|
||||
]
|
||||
lines = dev.table_rows(rows, 20.0)
|
||||
assert lines[0] == {
|
||||
"station": "No.22+00~No.26+05",
|
||||
"distance": 85.0,
|
||||
"height": 3.5,
|
||||
"area": 297.5,
|
||||
}
|
||||
assert lines[1]["area"] == 490.0
|
||||
drawing = build_development_drawing(
|
||||
"development_concrete",
|
||||
{
|
||||
"title": "콘크리트포장 전개도",
|
||||
"group": "concrete",
|
||||
"form": "",
|
||||
"rows": rows,
|
||||
"missing": 0,
|
||||
},
|
||||
20.0,
|
||||
)
|
||||
table = next(e for e in drawing["entities"] if e["type"] == "Table")
|
||||
cells = table["shapeData"]["cells"]
|
||||
assert cells[0][2]["text"] == "폭"
|
||||
assert [c["text"] for c in cells[2]] == ["No.22+00~No.26+05", "85.0", "3.5", "297.5"]
|
||||
assert [c["text"] for c in cells[-1]] == ["계", "225.0", "", "787.5"]
|
||||
|
||||
|
||||
def test_묶음_거리_높이(root):
|
||||
sheets = dev.development_sheets(dev.development_rows(root, DESIGNS))
|
||||
ids = [s["id"] for s in sheets]
|
||||
# 차례 = 관보호공 → BOX → 흙막이 → 옹벽 → 콘크리트포장 · 형태마다 한 장
|
||||
assert ids == [
|
||||
"development_pipe0",
|
||||
"development_pipe1",
|
||||
"development_box1",
|
||||
"development_soil0",
|
||||
"development_soil1",
|
||||
"development_wall2",
|
||||
"development_concrete",
|
||||
]
|
||||
pipe = _sheet(sheets, "development_pipe0")
|
||||
assert pipe["title"] == "관보호공 돌쌓기(찰) 전개도"
|
||||
assert [(r["start_m"], r["end_m"], r["height_m"]) for r in pipe["rows"]] == [(85.0, 91.0, 2.0)]
|
||||
meh = _sheet(sheets, "development_pipe1")["rows"]
|
||||
# 유출 길이 8 반씩(84~92 · 높이 빈칸 = 관경 최소 1.3) · 추가 흙막이 1(86~92.5) · 2 는 다단 밖
|
||||
assert [(r["start_m"], r["end_m"], r["height_m"]) for r in meh] == [
|
||||
(84.0, 92.0, 1.3),
|
||||
(86.0, 92.5, 1.5),
|
||||
]
|
||||
box = _sheet(sheets, "development_box1")
|
||||
assert box["title"] == "BOX암거 흙막이 돌쌓기(메) 전개도"
|
||||
assert [(r["start_m"], r["end_m"], r["height_m"]) for r in box["rows"]] == [(297.0, 303.0, 2.5)]
|
||||
# 추가 단 = 별도 박스(자기 형태 장) · 숨김 돌쌓기(메) = 흙막이 묶음 · 높이 빈칸
|
||||
assert [(r["height_m"]) for r in _sheet(sheets, "development_soil0")["rows"]] == [1.0]
|
||||
soil = _sheet(sheets, "development_soil1")["rows"]
|
||||
assert [(r["start_m"], r["height_m"]) for r in soil] == [(80.0, 2.0), (100.0, None)]
|
||||
wall = _sheet(sheets, "development_wall2")
|
||||
assert wall["title"] == "옹벽 콘크리트 전개도" and wall["rows"][0]["height_m"] == 3.0
|
||||
pave = _sheet(sheets, "development_concrete")
|
||||
assert pave["title"] == "콘크리트포장 전개도"
|
||||
assert [r["height_m"] for r in pave["rows"]] == [3.5, 4.5] # 빈 폭 = (4 + 5 + 4.5) / 3
|
||||
# 표 · 끝 글 = 면적이 쓴 폭 그대로(3.55 를 3.5 로 줄여 보이지 않음)
|
||||
odd = {**pave, "rows": [{**pave["rows"][0], "height_m": 3.55}]}
|
||||
drawing = build_development_drawing(odd["id"], odd, 20.0)
|
||||
cells = next(e for e in drawing["entities"] if e["type"] == "Table")["shapeData"]["cells"]
|
||||
assert [c["text"] for c in cells[2]] == ["No.22+00~No.26+05", "85.0", "3.55", "301.8"]
|
||||
labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"]
|
||||
assert "B=3.55" in labels
|
||||
|
||||
|
||||
def test_설계_없으면_단수_안_거름_목록(root):
|
||||
rows = dev.development_rows(root)
|
||||
meh = [r for r in rows if r["group"] == "pipe" and r["form"] == "돌쌓기(메)"]
|
||||
assert len(meh) == 3 # 추가 흙막이 2 도(단 수 모름) · 2 는 길이 빈칸
|
||||
sheet = _sheet(dev.development_sheets(rows), "development_pipe1")
|
||||
assert sheet["missing"] == 1 and len(sheet["rows"]) == 2
|
||||
items = dev.development_list(root, {})
|
||||
assert {i.kind for i in items} == {"development"}
|
||||
assert (items[0].plan_kind, items[0].plan_kind_name) == ("development_pipe", "관보호공")
|
||||
assert items[0].label == "관보호공 돌쌓기(찰) 전개도"
|
||||
|
||||
|
||||
def test_넘치면_2장():
|
||||
rows = [dev._row("soil", "돌망태", i * 20.0, i * 20.0 + 6, 2.0, str(i)) for i in range(35)]
|
||||
sheets = dev.development_sheets(rows)
|
||||
assert [s["id"] for s in sheets] == ["development_soil3", "development_soil3_2"]
|
||||
assert [len(s["rows"]) for s in sheets] == [30, 5]
|
||||
assert sheets[1]["title"] == "흙막이 돌망태 전개도 (2)"
|
||||
|
||||
|
||||
def test_높이_빈칸_박스와_미입력_글_무늬_DXF(root):
|
||||
sheets = dev.development_sheets(dev.development_rows(root, DESIGNS))
|
||||
sheet = _sheet(sheets, "development_soil1")
|
||||
drawing = build_development_drawing(sheet["id"], sheet, 20.0)
|
||||
texts = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"]
|
||||
assert "흙막이 돌쌓기(메) 전개도" in texts
|
||||
assert "No.04+00" in texts and "No.05+10" in texts
|
||||
assert "H=2.0" in texts and "H=미입력" in texts and "L=8.0m" in texts
|
||||
fills = [e for e in drawing["entities"] if e["layerId"] == FILL_LAYER_ID]
|
||||
assert len(fills) > 10 # 돌 무늬
|
||||
table = next(e for e in drawing["entities"] if e["type"] == "Table")
|
||||
assert [c["text"] for c in table["shapeData"]["cells"][-1]] == ["계", "18.0", "", "16.0"]
|
||||
# 같은 자료 = 같은 그림(씨앗 고정)
|
||||
assert build_development_drawing(sheet["id"], sheet, 20.0) == drawing
|
||||
dxf, _skipped = drawing_to_dxf(drawing)
|
||||
text = dxf.decode("utf-8", "ignore")
|
||||
assert "b07-dev-fill" in text and "No.04+00" in text and "297.5" not in text
|
||||
assert text.count("LINE\r\n") > 50 # 돌 무늬 = 선(DXF 에도 남음)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"form", ["돌쌓기(찰)", "돌쌓기(메)", "돌망태", "통나무·목재틀", "콘크리트"]
|
||||
)
|
||||
def test_무늬는_박스_안(form):
|
||||
for seed in range(30):
|
||||
width, height = 10 + seed * 9.7, 8 + seed % 7 * 5.0
|
||||
for entity in fill_entities(f"s{seed}", form, "soil", (0.0, 0.0, width, height)):
|
||||
for one in entity.get("children") or [entity]:
|
||||
shape = one["shapeData"]
|
||||
if "center" in shape:
|
||||
r, (x, y) = shape["radius"], (shape["center"]["x"], shape["center"]["y"])
|
||||
points = [(x - r, y - r), (x + r, y + r)]
|
||||
else:
|
||||
points = [(p["x"], p["y"]) for p in (shape["startPoint"], shape["endPoint"])]
|
||||
for x, y in points:
|
||||
assert -1e-6 <= x <= width + 1e-6 and -1e-6 <= y <= height + 1e-6, (seed, x, y)
|
||||
|
||||
|
||||
def test_목록_전_장_없으면_빈_장(root, tmp_path):
|
||||
long_path = tmp_path / "long.json"
|
||||
long_path.write_text(json.dumps({"stations": []}), encoding="utf-8")
|
||||
kind, label, drawing, confirmed, _ = dev.read_development(
|
||||
root, long_path, "development_pipe1_3", DESIGNS
|
||||
)
|
||||
assert kind == "development" and label == "관보호공 돌쌓기(메) 전개도" and not confirmed
|
||||
with pytest.raises(ValueError):
|
||||
dev.read_development(root, long_path, "development_x-1", DESIGNS)
|
||||
|
||||
|
||||
def test_확정본은_전개도_kind_로_읽음(root, tmp_path):
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import _design_root, _read_drawing
|
||||
|
||||
stage = _design_root(root)
|
||||
(stage / "drawings").mkdir(parents=True)
|
||||
saved = {"format": DRAWING_FORMAT, "entities": [], "layers": []}
|
||||
(stage / "drawings" / "development_pipe0.json").write_text(json.dumps(saved), "utf-8")
|
||||
manifest = {"drawings": {"development_pipe0": {"confirmed": True, "label": "관보호공"}}}
|
||||
(stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), "utf-8")
|
||||
kind, label, drawing, confirmed, table = _read_drawing(
|
||||
root, tmp_path / "long.json", "development_pipe0"
|
||||
)
|
||||
assert (kind, label, confirmed, table) == ("development", "관보호공", True, None)
|
||||
@@ -65,6 +65,7 @@ def test_B07_첫_그림_접힘(out):
|
||||
"cross": True,
|
||||
"mass_haul": False,
|
||||
"structure": False, # 376-2 늘 카드 · 기본 펼침
|
||||
"development": False, # 377 전개도 카드 — 기본 펼침
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +76,7 @@ def test_B07_다시_세워도_접힘_이어_받음(out):
|
||||
"cross": False,
|
||||
"mass_haul": True,
|
||||
"structure": False,
|
||||
"development": False,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user