feat(B07): 횡단면도 장 배치 장마다 열 × 행 칸 가운데 · 도각 고정 그림칸(140-3)
- 쌓기 · 최소 장수 함수 지움 · grid_slots(n, 열, 행) 칸 가운데(좌하단 → 위 → 오른쪽) · 1/100 그대로
- 저장 B07_DesignDetail/cross_layout.json {default [4,4], sheets {장 번호: [열, 행]}} · GET · PUT cross-layout
- 배치 바꾸면 담긴 측점이 바뀐 확정 장 풂 · 그 측점 설계 잠정 · CROSS_SHEET_LAYOUT = 3
- 장 나눔이 설계 · 덧그림과 무관 — 목록 · 확정 · 수정에서 덧그림 재기 빠짐
- 좌측 정보 칸 「배치 열 × 행 [적용] · [모든 장 기본]」 → 다시 세워 같은 장 번호 엶
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
This commit is contained in:
@@ -175,6 +175,24 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P
|
||||
);
|
||||
}
|
||||
|
||||
/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3). */
|
||||
export interface CrossLayout {
|
||||
default: [number, number];
|
||||
sheets: Record<string, [number, number]>;
|
||||
}
|
||||
|
||||
export function fetchCrossLayout(projectId: string): Promise<CrossLayout> {
|
||||
return requestJson(`/projects/${projectId}/cross-layout`);
|
||||
}
|
||||
|
||||
/** 배치 저장 — 담긴 측점이 바뀐 확정 장은 서버가 풂. */
|
||||
export function saveCrossLayout(projectId: string, layout: CrossLayout): Promise<CrossLayout> {
|
||||
return requestJson(`/projects/${projectId}/cross-layout`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(layout),
|
||||
});
|
||||
}
|
||||
|
||||
/** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */
|
||||
export interface PlanKind {
|
||||
id: string;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""B07 횡단면도 장 배치 — A1 한 장에 들어가는 만큼 단면을 담는다.
|
||||
"""B07 횡단면도 장 배치 — 장마다 열 × 행 칸(기본 4 × 4)에 단면을 하나씩 담는다(140-3).
|
||||
|
||||
채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다
|
||||
(2026-08-29 사용자 확정). 한 장에 몇 개가 들어가는지는 단면 블록의 크기가
|
||||
정하며, 블록 크기는 작성 척도(1/100)와 "설계선이 원지반과 갈라지는 구간"이
|
||||
정한다 — 즉 종이가 허용하는 만큼만 담고 남으면 다음 장으로 넘긴다.
|
||||
(2026-08-29 사용자 확정). 칸 = 도각 그림칸(usable_area)을 똑같이 나눈 자리 · 단면은
|
||||
칸 가운데 · 작성 척도 1/100 그대로(큰 단면이 칸을 넘으면 겹침 — 사용자 배치로 고침).
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -95,111 +94,52 @@ def section_block_size(
|
||||
return (width, top + below)
|
||||
|
||||
|
||||
def _stack_columns(group: list[tuple[int | float, float, float]]) -> list[list[int]]:
|
||||
"""블록 순번을 열로 묶는다 — 아래에서 위로 쌓다 높이가 넘치면 오른쪽 새 열."""
|
||||
_usable_w, usable_h = usable_area()
|
||||
columns: list[list[int]] = []
|
||||
height = 0.0
|
||||
for index, (_c, _w, block_h) in enumerate(group):
|
||||
if not columns or height + block_h > usable_h + 1e-6:
|
||||
columns.append([])
|
||||
height = 0.0
|
||||
columns[-1].append(index)
|
||||
height += block_h
|
||||
return columns
|
||||
|
||||
|
||||
def _fits(group: list[tuple[int | float, float, float]]) -> bool:
|
||||
"""group 을 한 장에 담을 수 있나 — 열 폭 합이 가로를, 열마다 높이 합이 세로를 넘지 않는다."""
|
||||
def grid_slots(n: int, cols: int, rows: int) -> list[list[float]]:
|
||||
"""n 개 칸 가운데 — 그림칸(usable_area)을 열 × 행으로 똑같이 나눔 · 좌하단 → 위 → 오른쪽 열."""
|
||||
usable_w, usable_h = usable_area()
|
||||
columns = _stack_columns(group)
|
||||
width = sum(max(group[i][1] for i in column) for column in columns)
|
||||
tallest = max(sum(group[i][2] for i in column) for column in columns)
|
||||
return width <= usable_w + 1e-6 and tallest <= usable_h + 1e-6
|
||||
cell_w, cell_h = usable_w / cols, usable_h / rows
|
||||
return [
|
||||
[
|
||||
-usable_w / 2.0 + (index // rows + 0.5) * cell_w,
|
||||
-usable_h / 2.0 + (index % rows + 0.5) * cell_h,
|
||||
]
|
||||
for index in range(n)
|
||||
]
|
||||
|
||||
|
||||
def _max_take(blocks: list[tuple[int | float, float, float]], start: int) -> int:
|
||||
"""blocks[start:] 를 한 장에 담을 수 있는 최대 개수."""
|
||||
limit = 0
|
||||
for take in range(1, len(blocks) - start + 1):
|
||||
if not _fits(blocks[start : start + take]):
|
||||
break
|
||||
limit = take
|
||||
return limit
|
||||
def sheet_grid(layout: dict[str, Any] | None, number: int) -> tuple[int, int]:
|
||||
"""장 번호의 (열, 행) — 장마다 값이 없으면 기본(없으면 4 × 4)."""
|
||||
layout = layout or {}
|
||||
grid = (layout.get("sheets") or {}).get(str(number)) or layout.get("default") or (4, 4)
|
||||
return int(grid[0]), int(grid[1])
|
||||
|
||||
|
||||
def _sheet_breaks(blocks: list[tuple[int, float, float]]) -> list[int]:
|
||||
"""장 경계를 **전체 최소 장수**가 되도록 고른다 (측점 순서는 유지).
|
||||
def plan_cross_sheets(
|
||||
chainages: list[int | float], layout: dict[str, Any] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""측점 목록을 A1 장으로 나눈다 — 장마다 열 × 행 칸(기본 4 × 4 · 140-3).
|
||||
|
||||
앞에서부터 최대한 채우면 바로 뒤에 큰 단면이 오는 순간 칸이 그 단면 크기로
|
||||
튀어 그 장이 통째로 비었다(2026-09-04 실측: 6칸짜리 장에 1개만 배치). 단면
|
||||
크기는 측점마다 원지반 기울기로 달라지므로, 경계를 뒤에서부터 훑어 최소 장수
|
||||
조합을 고른다 — 큰 단면은 자기 장에 몰리고 비슷한 크기끼리 한 장에 모인다.
|
||||
같은 장수면 **앞 장을 더 많이 채우는 쪽**을 고른다(뒷장에 여백을 몰아 준다).
|
||||
"""
|
||||
total = len(blocks)
|
||||
best_sheets = [0] * (total + 1)
|
||||
best_take = [0] * (total + 1)
|
||||
for start in range(total - 1, -1, -1):
|
||||
limit = max(_max_take(blocks, start), 1)
|
||||
choice = (total + 1, 0)
|
||||
for take in range(1, limit + 1):
|
||||
candidate = (best_sheets[start + take] + 1, -take)
|
||||
if candidate < choice:
|
||||
choice = candidate
|
||||
best_sheets[start], best_take[start] = choice[0], -choice[1]
|
||||
breaks: list[int] = []
|
||||
start = 0
|
||||
while start < total:
|
||||
breaks.append(best_take[start])
|
||||
start += best_take[start]
|
||||
return breaks
|
||||
|
||||
|
||||
def _slots(group: list[tuple[int | float, float, float]]) -> list[list[float]]:
|
||||
"""블록의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 쌓고, 열이 차면 오른쪽 열.
|
||||
|
||||
블록은 자기 크기 그대로 맞닿게 쌓는다 — 칸을 장 최대 블록으로 통일하면 작은
|
||||
단면의 테두리가 그림에서 수십 mm 떨어졌다(68-1). 열 폭은 그 열 최대 블록 폭이고
|
||||
블록은 열 가운데에 선다.
|
||||
"""
|
||||
usable_w, usable_h = usable_area()
|
||||
slots: list[list[float]] = [[0.0, 0.0] for _ in group]
|
||||
left = -usable_w / 2.0
|
||||
for column in _stack_columns(group):
|
||||
column_w = max(group[i][1] for i in column)
|
||||
bottom = -usable_h / 2.0
|
||||
for i in column:
|
||||
slots[i] = [left + column_w / 2.0, bottom]
|
||||
bottom += group[i][2]
|
||||
left += column_w
|
||||
return slots
|
||||
|
||||
|
||||
def plan_cross_sheets(blocks: list[tuple[int | float, float, float]]) -> list[dict[str, Any]]:
|
||||
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
|
||||
|
||||
블록은 자기 크기(그림 + 테두리 1 mm)로 열마다 쌓는다. 작성 척도는 1/100 고정 — 안
|
||||
들어가면 장을 나눌 뿐 줄이지 않는다(지식DB 「설계제원_총괄」 측량·도면 기준). 장에
|
||||
담기는 측점 수는 세트마다 다르다(2026-09-04 사용자 확정).
|
||||
단면 크기와 무관하게 칸 수만큼 담는다(큰 단면이 칸을 넘는 겹침은 사용자가 배치로 고침).
|
||||
작성 척도는 1/100 고정 — 줄이지 않는다(지식DB 「설계제원_총괄」 측량·도면 기준).
|
||||
"""
|
||||
sheets: list[dict[str, Any]] = []
|
||||
start = 0
|
||||
for count in _sheet_breaks(blocks):
|
||||
group = blocks[start : start + count]
|
||||
chainages = [chainage for chainage, _w, _h in group]
|
||||
while start < len(chainages):
|
||||
cols, rows = sheet_grid(layout, len(sheets) + 1)
|
||||
group = list(chainages[start : start + cols * rows])
|
||||
sheets.append(
|
||||
{
|
||||
# id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가
|
||||
# id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 배치가
|
||||
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
|
||||
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
|
||||
"id": f"cross_s{cross_key(chainages[0])}m",
|
||||
"id": f"cross_s{cross_key(group[0])}m",
|
||||
"number": len(sheets) + 1,
|
||||
"chainages": chainages,
|
||||
"slots": _slots(group),
|
||||
"chainages": group,
|
||||
"grid": [cols, rows],
|
||||
"slots": grid_slots(len(group), cols, rows),
|
||||
}
|
||||
)
|
||||
start += count
|
||||
start += len(group)
|
||||
return sheets
|
||||
|
||||
|
||||
@@ -227,10 +167,9 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
if bbox is None:
|
||||
continue
|
||||
min_x, min_y, max_x, max_y = bbox
|
||||
center_x, bottom_y = slots[index]
|
||||
# 2) 가로는 칸 가운데, 세로는 칸 아래 변에 테두리 아래 변을 맞춰 다시 만든다.
|
||||
# 테두리는 자기 그림 + 1 mm — 칸 크기로 넓히지 않는다(68-1).
|
||||
origin = (center_x - (min_x + max_x) / 2.0, bottom_y - min_y)
|
||||
center_x, center_y = slots[index]
|
||||
# 2) 블록(테두리 = 자기 그림 + 1 mm) 가운데를 칸 가운데에 맞춰 다시 만든다.
|
||||
origin = (center_x - (min_x + max_x) / 2.0, center_y - (min_y + max_y) / 2.0)
|
||||
placed = build_cross_drawing(
|
||||
section["source"],
|
||||
seed_id,
|
||||
@@ -244,12 +183,13 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
entities.extend(placed["entities"])
|
||||
placements.extend(placed.get("cross_placements") or [])
|
||||
|
||||
bbox = entities_bbox(entities)
|
||||
if bbox:
|
||||
if entities:
|
||||
# 도각 = 고정 그림칸 — 내용 크기와 무관하게 칸 격자와 같은 자리(140-3).
|
||||
usable_w, usable_h = usable_area()
|
||||
entities.extend(
|
||||
frame_entities(
|
||||
sheet["id"],
|
||||
bbox,
|
||||
(-usable_w / 2.0, -usable_h / 2.0, usable_w / 2.0, usable_h / 2.0),
|
||||
fit=False,
|
||||
fields={"도면명": "횡단면도", **scale_fields(("", DRAWING_SCALE_CROSS))},
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ from uuid import UUID
|
||||
from aiomysql import DictCursor
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
@@ -45,6 +46,8 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
_read_drawing,
|
||||
_read_json,
|
||||
_store_confirmed_drawing,
|
||||
read_cross_layout,
|
||||
write_cross_layout,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source
|
||||
@@ -267,11 +270,7 @@ async def get_design_drawing_list(
|
||||
"""B07 좌측 패널용 도면 메타데이터만 캐시한다."""
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||
designs = await _designs_by_chainage(route_id)
|
||||
overlays = await cross_overlay_extents(project_id, route_id)
|
||||
drawings = await asyncio.to_thread(
|
||||
_drawing_list, project_root, longitudinal_path, designs, overlays
|
||||
)
|
||||
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
|
||||
return DesignDrawingListResponse(
|
||||
project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass
|
||||
)
|
||||
@@ -394,18 +393,13 @@ async def confirm_design_drawing(
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||
designs = await _designs_by_chainage(route_id)
|
||||
overlays = await cross_overlay_extents(project_id, route_id)
|
||||
items = await asyncio.to_thread(
|
||||
_drawing_list, project_root, longitudinal_path, designs, overlays
|
||||
)
|
||||
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
|
||||
item = next((candidate for candidate in items if candidate.id == drawing_id), None)
|
||||
if not item:
|
||||
raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.")
|
||||
sheet = None
|
||||
if CROSS_SHEET_ID.fullmatch(drawing_id):
|
||||
plan = await asyncio.to_thread(
|
||||
_cross_sheet_plan, project_root, longitudinal_path, designs, overlays
|
||||
)
|
||||
plan = await asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path)
|
||||
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
|
||||
# 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백.
|
||||
# 장에는 측점이 여럿이라 측점별로 뽑는다 (엔티티 id 씨앗 = "{장id}:{측점}").
|
||||
@@ -498,14 +492,8 @@ async def invalidate_design_drawing(
|
||||
"""확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다."""
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||
# 목록은 **설계값을 넣어** 만든다 — 장 나눔이 측점 표시 폭에 따라 달라지므로,
|
||||
# 설계 없이 만들면 방금 확정한 장 id 가 목록에 없어 [수정]이 404 로 막힌다
|
||||
# (2026-09-03 실측: `cross_s00220m` 확정 후 확정 해제 불가).
|
||||
designs = await _designs_by_chainage(route_id)
|
||||
overlays = await cross_overlay_extents(project_id, route_id)
|
||||
items = await asyncio.to_thread(
|
||||
_drawing_list, project_root, longitudinal_path, designs, overlays
|
||||
)
|
||||
# 장 나눔은 배치(열 × 행)만 따름 — 설계 · 덧그림 없이 만들어도 같은 장 id(140-3).
|
||||
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
|
||||
if drawing_id not in {item.id for item in items}:
|
||||
raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.")
|
||||
await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id)
|
||||
@@ -515,9 +503,7 @@ async def invalidate_design_drawing(
|
||||
if cross_match:
|
||||
stale = [parse_cross_key(cross_match.group(1))]
|
||||
elif CROSS_SHEET_ID.fullmatch(drawing_id):
|
||||
plan = await asyncio.to_thread(
|
||||
_cross_sheet_plan, project_root, longitudinal_path, designs, overlays
|
||||
)
|
||||
plan = await asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path)
|
||||
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
|
||||
stale = list(sheet["chainages"]) if sheet else []
|
||||
pool = get_db_pool()
|
||||
@@ -554,3 +540,52 @@ async def invalidate_design_drawing(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
class CrossLayoutRequest(BaseModel):
|
||||
"""횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3)."""
|
||||
|
||||
default: list[int] = Field(default_factory=lambda: [4, 4])
|
||||
sheets: dict[str, list[int]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/{project_id}/cross-layout")
|
||||
async def get_cross_layout(project_id: UUID) -> Any:
|
||||
"""횡단 장 배치(파일 없으면 기본 4 × 4)."""
|
||||
_route_id, project_root, _longitudinal_path, _bypass = await _confirmed_source(project_id)
|
||||
return await asyncio.to_thread(read_cross_layout, project_root)
|
||||
|
||||
|
||||
@router.put("/{project_id}/cross-layout")
|
||||
async def put_cross_layout(project_id: UUID, request: CrossLayoutRequest) -> Any:
|
||||
"""횡단 장 배치를 쓴다 — 담긴 측점이 바뀐 확정 장은 풀고 그 측점 설계를 잠정으로([수정] 꼴)."""
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, _bypass = await _confirmed_source(project_id)
|
||||
stale = await asyncio.to_thread(
|
||||
write_cross_layout, project_root, longitudinal_path, request.model_dump()
|
||||
)
|
||||
if stale:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
for chainage in stale:
|
||||
await merge_cross_section_design_by_round(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
chainage=chainage,
|
||||
patch={"status": "provisional"},
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 4)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return await asyncio.to_thread(read_cross_layout, project_root)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except PermissionError as exc:
|
||||
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
|
||||
|
||||
@@ -34,7 +34,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
CROSS_SHEET_ID,
|
||||
build_cross_sheet,
|
||||
plan_cross_sheets,
|
||||
section_block_size,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import (
|
||||
STANDARD_LABEL,
|
||||
@@ -82,6 +81,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingItem,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import DRAWING_SCALE_PLAN
|
||||
|
||||
@@ -97,16 +97,67 @@ MASS_HAUL_ID = "mass_haul"
|
||||
COVER_ID = "cover"
|
||||
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
|
||||
CROSS_STANDARD_ID = "cross_standard"
|
||||
# 횡단 장 배치 판 — 단면마다 자기 그림 + 1 mm 박스(68-1). 이 판 전에 확정한 장은 옛 배치다.
|
||||
CROSS_SHEET_LAYOUT = 2
|
||||
# 횡단 장 배치 판 — 장마다 열 × 행 칸 가운데(140-3). 이 판 전에 확정한 장은 옛 배치다.
|
||||
CROSS_SHEET_LAYOUT = 3
|
||||
# 장마다 열 × 행 — {default: [열, 행], sheets: {장 번호: [열, 행]}}(140-3).
|
||||
CROSS_LAYOUT_FILE = "cross_layout.json"
|
||||
CROSS_GRID_DEFAULT = [4, 4]
|
||||
_CROSS_GRID_MAX = 10
|
||||
|
||||
|
||||
def _drawing_list(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
designs: dict[int | float, dict[str, Any]] | None = None,
|
||||
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
|
||||
) -> list[DesignDrawingItem]:
|
||||
def _cross_grid(value: Any) -> list[int]:
|
||||
if (
|
||||
not isinstance(value, (list, tuple))
|
||||
or len(value) != 2
|
||||
or not all(isinstance(n, int) and 1 <= n <= _CROSS_GRID_MAX for n in value)
|
||||
):
|
||||
raise ValueError(f"배치는 열 · 행 1~{_CROSS_GRID_MAX} 사이 정수로 적어 주세요.")
|
||||
return [int(value[0]), int(value[1])]
|
||||
|
||||
|
||||
def read_cross_layout(project_root: Path) -> dict[str, Any]:
|
||||
"""횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 만."""
|
||||
path = _design_root(project_root) / CROSS_LAYOUT_FILE
|
||||
try:
|
||||
return checked_cross_layout(_read_json(path))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}}
|
||||
|
||||
|
||||
def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]:
|
||||
sheets = layout.get("sheets") or {}
|
||||
if not all(str(key).isdigit() and int(key) >= 1 for key in sheets):
|
||||
raise ValueError("장 번호가 올바르지 않습니다.")
|
||||
return {
|
||||
"default": _cross_grid(layout.get("default") or CROSS_GRID_DEFAULT),
|
||||
"sheets": {str(int(key)): _cross_grid(grid) for key, grid in sheets.items()},
|
||||
}
|
||||
|
||||
|
||||
def write_cross_layout(
|
||||
project_root: Path, longitudinal_path: Path, layout: dict[str, Any]
|
||||
) -> list[int | float]:
|
||||
"""배치를 쓰고 담긴 측점이 바뀐 확정 장을 푼다 — 풀린 장의 옛 측점들(설계 상태를 되돌릴 몫)."""
|
||||
checked = checked_cross_layout(layout)
|
||||
before = _cross_sheet_plan(project_root, longitudinal_path)
|
||||
atomic_write_json(_design_root(project_root) / CROSS_LAYOUT_FILE, checked)
|
||||
after = {
|
||||
sheet["id"]: sheet["chainages"]
|
||||
for sheet in _cross_sheet_plan(project_root, longitudinal_path)
|
||||
}
|
||||
manifest = _read_manifest(project_root)
|
||||
stale: list[int | float] = []
|
||||
for sheet in before:
|
||||
entry = manifest["drawings"].get(sheet["id"])
|
||||
if entry and entry.get("confirmed") and after.get(sheet["id"]) != sheet["chainages"]:
|
||||
entry["confirmed"] = False
|
||||
stale.extend(sheet["chainages"])
|
||||
if stale:
|
||||
_write_manifest(project_root, manifest)
|
||||
return stale
|
||||
|
||||
|
||||
def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]:
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
station_by_chainage = _station_map(longitudinal)
|
||||
manifest_drawings = _read_manifest(project_root)["drawings"]
|
||||
@@ -123,7 +174,7 @@ def _drawing_list(
|
||||
# 횡단 장 계획이 실패해도 나머지 도면은 목록에 남긴다 — 예전에는 `cross_sections/`
|
||||
# 하나가 없으면 표지·종단면도·토적도·유역도까지 함께 사라졌다(2026-09-01 지적).
|
||||
try:
|
||||
sheets = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays)
|
||||
sheets = _cross_sheet_plan(project_root, longitudinal_path)
|
||||
except (FileNotFoundError, ValueError, OSError) as exc:
|
||||
logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc)
|
||||
sheets = []
|
||||
@@ -190,32 +241,21 @@ def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem]
|
||||
_write_manifest(project_root, manifest)
|
||||
|
||||
|
||||
def _cross_sheet_plan(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
designs: dict[int | float, dict[str, Any]] | None = None,
|
||||
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
def _cross_sheet_plan(project_root: Path, longitudinal_path: Path) -> list[dict[str, Any]]:
|
||||
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
|
||||
|
||||
한 장에 몇 개가 들어가는지는 단면 블록 크기가 정한다(작성 척도 1/100 +
|
||||
설계선이 원지반과 갈라지는 구간). 설계 지정이 없으면 원지반 기준 폭이 된다.
|
||||
장마다 열 × 행 칸 수만큼 담는다(140-3 · 단면 크기와 무관 — 설계 · 덧그림에 장 id 가 안 흔들림).
|
||||
"""
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
blocks: list[tuple[int, float, float]] = []
|
||||
chainages: list[int | float] = []
|
||||
for path in sorted(_cross_files(longitudinal_path, longitudinal)):
|
||||
match = _CROSS_ID.fullmatch(path.stem)
|
||||
if not match:
|
||||
continue
|
||||
source = _read_json(path)
|
||||
# 측점은 파일 속 값으로 — 옛 프로젝트는 소수 측점 파일이 정수 m 이름이라 이름으로 못 앎
|
||||
chainage = cross_station(source.get("chainage_m") or parse_cross_key(match.group(1)))
|
||||
design = (designs or {}).get(chainage)
|
||||
design_line = _cross_design_line(longitudinal_path, source, design)
|
||||
overlay = (overlays or {}).get(chainage) # 화면 덧그림 범위(68-3)
|
||||
width, height = section_block_size(source, design_line, design=design, overlay=overlay)
|
||||
blocks.append((chainage, width, height))
|
||||
return plan_cross_sheets(blocks)
|
||||
chainages.append(cross_station(source.get("chainage_m") or parse_cross_key(match.group(1))))
|
||||
return plan_cross_sheets(chainages, read_cross_layout(project_root))
|
||||
|
||||
|
||||
def _quantity_table(
|
||||
@@ -353,7 +393,7 @@ def _read_cross_sheet(
|
||||
"""횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정}, overlays는 {측점: 덧그림 범위}."""
|
||||
designs = stored_designs if isinstance(stored_designs, dict) else {}
|
||||
overlays = overlays or {}
|
||||
plan = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays)
|
||||
plan = _cross_sheet_plan(project_root, longitudinal_path)
|
||||
sheet = next((item for item in plan if item["id"] == drawing_id), None)
|
||||
if sheet is None:
|
||||
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
|
||||
|
||||
@@ -22,6 +22,7 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
// 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||
import {
|
||||
buildCrossLayoutControls,
|
||||
buildDesignInfoPanel,
|
||||
buildDrawingSidePanel,
|
||||
isCrossSheet,
|
||||
@@ -36,14 +37,17 @@ import {
|
||||
import {
|
||||
confirmDesignDrawing,
|
||||
exportDrawing,
|
||||
fetchCrossLayout,
|
||||
fetchDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
fetchFrameTemplate,
|
||||
importFrameTemplate,
|
||||
invalidateDesignDrawing,
|
||||
resetFrameTemplate,
|
||||
saveCrossLayout,
|
||||
saveFrameTemplate,
|
||||
type CadDrawing,
|
||||
type CrossLayout,
|
||||
type DesignDrawingItem,
|
||||
type DesignDrawingResponse,
|
||||
type QuantityTable,
|
||||
@@ -81,6 +85,8 @@ function L(key: keyof typeof ui_locales): string {
|
||||
* -------------------------------------------------------------------------- */
|
||||
/** [평면도 관리]에서 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */
|
||||
let reopenDrawingId: string | undefined;
|
||||
/** 횡단 장 배치를 바꿔 다시 세울 때 — 보던 장 번호(배치가 바뀌면 장 id 가 바뀜 · 140-3). */
|
||||
let reopenCrossSheet: number | undefined;
|
||||
|
||||
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
@@ -138,8 +144,42 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은
|
||||
// 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다.
|
||||
const scope = isCrossSheet(drawing) ? "sheet" : "station";
|
||||
infoPanelHost.replaceChildren(
|
||||
buildDesignInfoPanel(drawing.label, response.design ?? null, scope),
|
||||
const panel = buildDesignInfoPanel(drawing.label, response.design ?? null, scope);
|
||||
infoPanelHost.replaceChildren(panel);
|
||||
if (scope === "sheet") void appendCrossLayout(drawing, panel);
|
||||
};
|
||||
|
||||
/** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. */
|
||||
const appendCrossLayout = async (drawing: DesignDrawingItem, panel: HTMLElement) => {
|
||||
if (!projectId) return;
|
||||
const number = drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1;
|
||||
let layout: CrossLayout;
|
||||
try {
|
||||
layout = await fetchCrossLayout(projectId);
|
||||
} catch {
|
||||
return; // 배치 칸만 빠짐 — 도면 보기는 그대로
|
||||
}
|
||||
if (currentDrawing?.id !== drawing.id || !panel.isConnected) return;
|
||||
const save = async (next: CrossLayout) => {
|
||||
if (!mayDiscardEdits()) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await saveCrossLayout(projectId, next);
|
||||
reopenCrossSheet = number;
|
||||
cad.destroy();
|
||||
void renderB07DesignDetail(root);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
panel.append(
|
||||
buildCrossLayoutControls(
|
||||
layout.sheets[String(number)] ?? layout.default,
|
||||
(grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }),
|
||||
(grid) => void save({ default: grid, sheets: {} }),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -523,8 +563,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
|
||||
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
|
||||
if (drawings.length > 0) {
|
||||
const reopen = drawings.findIndex((item) => item.id === reopenDrawingId);
|
||||
const crossSheets = drawings.filter(isCrossSheet);
|
||||
const reopenId = reopenCrossSheet
|
||||
? crossSheets[Math.min(reopenCrossSheet, crossSheets.length) - 1]?.id
|
||||
: reopenDrawingId;
|
||||
const reopen = drawings.findIndex((item) => item.id === reopenId);
|
||||
reopenDrawingId = undefined;
|
||||
reopenCrossSheet = undefined;
|
||||
const first = Math.max(reopen, 0);
|
||||
void openTab(drawings[first]).then(() => {
|
||||
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { 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 { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds";
|
||||
@@ -171,6 +172,58 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean {
|
||||
return drawing.kind === "cross" && drawing.id.startsWith("cross_s");
|
||||
}
|
||||
|
||||
/** 횡단 장 배치 칸(140-3) — 「배치 열 [4] × 행 [4] [적용]」 + [모든 장 기본]. */
|
||||
export function buildCrossLayoutControls(
|
||||
grid: [number, number],
|
||||
onApply: (grid: [number, number]) => void,
|
||||
onAllDefault: (grid: [number, number]) => void,
|
||||
): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.className = "b07-info__layout";
|
||||
const number = (value: number, label: string): HTMLInputElement => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "1";
|
||||
input.max = "10";
|
||||
input.step = "1";
|
||||
input.value = String(value);
|
||||
input.ariaLabel = label;
|
||||
return input;
|
||||
};
|
||||
const cols = number(grid[0], "열");
|
||||
const rows = number(grid[1], "행");
|
||||
const read = (): [number, number] | null => {
|
||||
const value: [number, number] = [Number(cols.value), Number(rows.value)];
|
||||
return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null;
|
||||
};
|
||||
const action = (text: string, title: string, run: (grid: [number, number]) => void) => {
|
||||
const node = document.createElement("button");
|
||||
node.type = "button";
|
||||
node.className = "b07-plan-tools__button";
|
||||
node.textContent = text;
|
||||
node.title = title;
|
||||
node.addEventListener("click", () => {
|
||||
const value = read();
|
||||
if (value) run(value);
|
||||
else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error");
|
||||
});
|
||||
return node;
|
||||
};
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "배치 열";
|
||||
const times = document.createElement("span");
|
||||
times.textContent = "× 행";
|
||||
box.append(
|
||||
label,
|
||||
cols,
|
||||
times,
|
||||
rows,
|
||||
action("적용", "이 장만 이 배치로", onApply),
|
||||
action("모든 장 기본", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
|
||||
);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
|
||||
function ditchLabel(design: CrossDesignInfo): string {
|
||||
const ditch = design.ditch;
|
||||
|
||||
@@ -225,6 +225,20 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 횡단 장 배치(140-3) — 배치 열 [4] × 행 [4] [적용] [모든 장 기본] */
|
||||
.b07-info__layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-info__layout input {
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
|
||||
.b07-drawing-list__bypass {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""140-3 B07 횡단면도 장 배치 — 장마다 열 × 행 칸(기본 4 × 4).
|
||||
|
||||
못박는 것
|
||||
- 칸 = 그림칸(usable_area)을 똑같이 나눈 자리 · 차례 = 좌하단 → 위 → 오른쪽 열.
|
||||
- 측점 73개 · 4 × 4 → 16 · 16 · 16 · 16 · 9 · 17번째 측점 = 2장 첫 칸.
|
||||
- 장마다 다른 배치(sheets {장 번호: [열, 행]}) · 기본 바꾸기.
|
||||
- 단면 블록 가운데 = 칸 가운데 · 블록 크기 = 1/100 그대로(section_block_size).
|
||||
- 도각 = 고정 그림칸 — 내용이 작아도 커도 같은 자리.
|
||||
- 배치 저장 · 읽기(깨진 값 거절) · 담긴 측점이 바뀐 확정 장만 풂.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
build_cross_sheet,
|
||||
grid_slots,
|
||||
plan_cross_sheets,
|
||||
section_block_size,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, usable_area
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
_cross_sheet_plan,
|
||||
read_cross_layout,
|
||||
write_cross_layout,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest
|
||||
|
||||
|
||||
def _source(chainage: float, slope: float, half: int = 10) -> dict:
|
||||
return {
|
||||
"chainage_m": chainage,
|
||||
"samples": [
|
||||
{"valid": True, "offset_m": float(x), "elevation_m": 100.0 + slope * x}
|
||||
for x in range(-half, half + 1)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_grid_slots_bottom_left_up_then_right() -> None:
|
||||
w, h = usable_area()
|
||||
slots = grid_slots(6, 4, 4)
|
||||
cw, ch = w / 4, h / 4
|
||||
assert slots[0] == pytest.approx([-w / 2 + cw / 2, -h / 2 + ch / 2]) # 좌하단
|
||||
assert slots[1] == pytest.approx([-w / 2 + cw / 2, -h / 2 + 1.5 * ch]) # 그 위
|
||||
assert slots[4] == pytest.approx([-w / 2 + 1.5 * cw, -h / 2 + ch / 2]) # 다음 열 맨 아래
|
||||
|
||||
|
||||
def test_73_stations_default_grid_17th_is_sheet_two() -> None:
|
||||
chainages = [i * 20.0 for i in range(73)]
|
||||
sheets = plan_cross_sheets(chainages)
|
||||
assert [len(s["chainages"]) for s in sheets] == [16, 16, 16, 16, 9]
|
||||
assert sheets[1]["chainages"][0] == chainages[16] # 17번째 측점 = 2장 첫 칸
|
||||
assert sheets[1]["number"] == 2 and sheets[1]["grid"] == [4, 4]
|
||||
assert sheets[1]["id"] == "cross_s00320m" # 장 id = 첫 측점
|
||||
|
||||
|
||||
def test_per_sheet_and_default_layout() -> None:
|
||||
chainages = [float(i) for i in range(40)]
|
||||
layout = {"default": [3, 3], "sheets": {"1": [2, 2], "3": [5, 4]}}
|
||||
sheets = plan_cross_sheets(chainages, layout)
|
||||
assert [s["grid"] for s in sheets] == [[2, 2], [3, 3], [5, 4], [3, 3]]
|
||||
assert [len(s["chainages"]) for s in sheets] == [4, 9, 20, 7]
|
||||
|
||||
|
||||
def _frame_bbox(drawing: dict) -> tuple:
|
||||
return entities_bbox([e for e in drawing["entities"] if e.get("layerId") == FRAME_LAYER_ID])
|
||||
|
||||
|
||||
def test_block_centered_in_cell_at_scale_and_frame_fixed() -> None:
|
||||
table = {"ground": 100.0, "planned": 100.0}
|
||||
small = [_source(0.0, 0.05, 6)]
|
||||
big = [_source(0.0, 0.6, 15), _source(20.0, 0.2, 8)]
|
||||
drawings = []
|
||||
for sources in (small, big):
|
||||
sheet = plan_cross_sheets([s["chainage_m"] for s in sources])[0]
|
||||
sections = [
|
||||
{"chainage": s["chainage_m"], "source": s, "quantity_table": table, "title": ""}
|
||||
for s in sources
|
||||
]
|
||||
drawing = build_cross_sheet(sheet, sections)
|
||||
for source, slot, placement in zip(
|
||||
sources, sheet["slots"], drawing["cross_placements"], strict=True
|
||||
):
|
||||
left, bottom, right, top = placement["frame"]
|
||||
# 블록 가운데 = 칸 가운데
|
||||
assert (left + right) / 2 == pytest.approx(slot[0])
|
||||
assert (bottom + top) / 2 == pytest.approx(slot[1])
|
||||
# 1/100 그대로 — 블록 크기가 자기 치수 그대로(칸 맞춤으로 줄이지 않음)
|
||||
width, height = section_block_size(source, None, design=None)
|
||||
assert right - left == pytest.approx(width, abs=0.5)
|
||||
drawings.append(drawing)
|
||||
# 도각 = 고정 그림칸 — 내용 크기와 무관하게 같은 자리
|
||||
assert _frame_bbox(drawings[0]) == pytest.approx(_frame_bbox(drawings[1]))
|
||||
|
||||
|
||||
def _project(tmp_path: Path, count: int) -> tuple[Path, Path]:
|
||||
root = tmp_path / "project"
|
||||
cross_dir = root / "B06_Section" / "cross_sections"
|
||||
cross_dir.mkdir(parents=True)
|
||||
longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json"
|
||||
longitudinal_path.parent.mkdir(parents=True)
|
||||
stations = []
|
||||
for i in range(count):
|
||||
chainage = i * 20
|
||||
stations.append({"chainage_m": float(chainage), "center_x": 0.0, "center_y": 0.0})
|
||||
(cross_dir / f"cross_{chainage:05d}m.json").write_text(
|
||||
json.dumps(_source(float(chainage), 0.1)), "utf-8"
|
||||
)
|
||||
longitudinal_path.write_text(json.dumps({"stations": stations}), "utf-8")
|
||||
return root, longitudinal_path
|
||||
|
||||
|
||||
def test_layout_store_and_release_changed_confirmed(tmp_path: Path) -> None:
|
||||
root, longitudinal_path = _project(tmp_path, 20)
|
||||
assert read_cross_layout(root) == {"default": [4, 4], "sheets": {}} # 파일 없으면 기본
|
||||
ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)]
|
||||
assert ids == ["cross_s00000m", "cross_s00320m"] # 16 + 4
|
||||
manifest = _read_manifest(root)
|
||||
manifest["drawings"] = {i: {"confirmed": True} for i in ids}
|
||||
_write_manifest(root, manifest)
|
||||
|
||||
# 2장만 3 × 3 — 1장 측점 그대로(확정 유지) · 2장 측점 그대로(4개 < 9칸) → 풀 장 없음
|
||||
assert (
|
||||
write_cross_layout(root, longitudinal_path, {"default": [4, 4], "sheets": {"2": [3, 3]}})
|
||||
== []
|
||||
)
|
||||
assert read_cross_layout(root)["sheets"] == {"2": [3, 3]}
|
||||
|
||||
# 1장 3 × 3 — 1장 9개로 줄고 2장은 다른 측점에서 시작 → 1장 풂(옛 측점 16개 돌려줌)
|
||||
stale = write_cross_layout(
|
||||
root, longitudinal_path, {"default": [4, 4], "sheets": {"1": [3, 3]}}
|
||||
)
|
||||
assert stale[:16] == [i * 20 for i in range(16)]
|
||||
drawings = _read_manifest(root)["drawings"]
|
||||
assert drawings["cross_s00000m"]["confirmed"] is False
|
||||
assert drawings["cross_s00320m"]["confirmed"] is False # 사라진 장 id 도 풂
|
||||
new_ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)]
|
||||
assert new_ids == ["cross_s00000m", "cross_s00180m"]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
write_cross_layout(root, longitudinal_path, {"default": [0, 4], "sheets": {}})
|
||||
with pytest.raises(ValueError):
|
||||
write_cross_layout(root, longitudinal_path, {"default": [4, 4], "sheets": {"x": [2, 2]}})
|
||||
@@ -72,8 +72,7 @@ _TABLE = {"ground": 100.0, "planned": 100.0}
|
||||
def test_each_block_hugs_its_own_drawing_by_one_mm() -> None:
|
||||
"""크기 다른 단면 셋을 한 장에 — 박스마다 자기 그림(표 포함)에서 사방 1.0 mm."""
|
||||
sources = [_source(0.0, 0.05), _source(20.0, 0.6, 15), _source(40.0, 0.2, 6)]
|
||||
blocks = [(s["chainage_m"], *section_block_size(s, None, design=None)) for s in sources]
|
||||
sheets = plan_cross_sheets(blocks)
|
||||
sheets = plan_cross_sheets([s["chainage_m"] for s in sources])
|
||||
assert len(sheets) == 1
|
||||
sections = [
|
||||
{"chainage": s["chainage_m"], "source": s, "quantity_table": _TABLE, "title": ""}
|
||||
@@ -100,13 +99,10 @@ def test_each_block_hugs_its_own_drawing_by_one_mm() -> None:
|
||||
assert top - max(b[3] for b in mine) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_small_blocks_are_not_widened_by_a_big_one() -> None:
|
||||
"""큰 단면 하나 + 작은 단면 여럿 — 칸을 큰 블록으로 통일하던 때보다 한 장에 더 담긴다."""
|
||||
blocks = [(0.0, 300.0, 300.0)] + [(float(i), 110.0, 60.0) for i in range(1, 30)]
|
||||
sheets = plan_cross_sheets(blocks)
|
||||
# 옛 배치(칸 300×300 → A1 739×499 에 2×1)는 첫 장에 2개였다.
|
||||
assert len(sheets[0]["chainages"]) > 2
|
||||
assert sum(len(s["chainages"]) for s in sheets) == 30
|
||||
def test_sheets_take_grid_count_not_block_size() -> None:
|
||||
"""140-3 장 나눔 = 칸 수(기본 4 × 4) — 단면 크기와 무관 · 측점 30개면 16 + 14."""
|
||||
sheets = plan_cross_sheets([float(i) for i in range(30)])
|
||||
assert [len(s["chainages"]) for s in sheets] == [16, 14]
|
||||
|
||||
|
||||
def test_window_stops_where_lines_end() -> None:
|
||||
|
||||
Reference in New Issue
Block a user