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:
2026-10-17 15:24:00 +09:00
10 changed files with 1141 additions and 0 deletions
@@ -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
+292
View File
@@ -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,
}