diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 4cec19747..696c3f773 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -39,6 +39,8 @@ export interface CadDrawing { }[]; /** 횡단 장 — 옛 배치(칸 통일)로 확정된 저장본. [수정]을 누르면 새 배치로 다시 만든다. */ stale_layout?: boolean; + /** 횡단 장 — 지금과 다른 표 · 글 배율로 확정된 저장본(180-2). */ + stale_scale?: boolean; layers: { id: string; name: string; @@ -178,10 +180,11 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P ); } -/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3). */ +/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2). */ export interface CrossLayout { default: [number, number]; sheets: Record; + table_scale?: number; } export function fetchCrossLayout(projectId: string): Promise { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 41a46d64b..2fd6e76a3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -498,6 +498,7 @@ def build_cross_drawing( overlay: tuple[float, float, float, float] | None = None, border: bool = True, table_bottom: float | None = None, + table_scale: float = 1.0, ) -> dict[str, Any]: """횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다. @@ -507,6 +508,7 @@ def build_cross_drawing( 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다. table_bottom(장 칸 · 174-1)을 주면 표 아래선을 그 y 에 두고 글 · 그림을 위로 쌓는다 — 표 · 글 가운데 x = 도로 중심(origin x) · origin y 는 쌓은 결과로 정함. + table_scale = 표 · 표 위 글 배율(180-2) — 8 mm 간격은 그대로. """ ox, oy = origin raw_ground = points_from_samples(source.get("samples", []), "offset_m") @@ -546,7 +548,11 @@ def build_cross_drawing( # 아래에서 위로 — 표 → 글(150-3) → 간격 8 → 그림(174-1) stack = 0.0 if quantity_table is not None: - stack = cross_table_height() + cross_note_height(cross_note_lines(design)) + 8.0 + stack = ( + cross_table_height(table_scale) + + cross_note_height(cross_note_lines(design), table_scale) + + 8.0 + ) oy = table_bottom + stack + own_half_height * CROSS_MM def paper(point: tuple[float, float]) -> tuple[float, float]: @@ -576,18 +582,23 @@ def build_cross_drawing( table_top = oy - half_height - 8.0 # 표 위 글(150-3) — 줄 수만큼 표를 내림 note_lines = cross_note_lines(design) - entities.extend(_cross_note_entities(drawing_id, note_lines, table_top, center_x)) - table_top -= cross_note_height(note_lines) entities.extend( - _cross_table_entities(drawing_id, quantity_table, table_top, title_label, center_x) + _cross_note_entities(drawing_id, note_lines, table_top, center_x, table_scale) ) - table_bottom = table_top - cross_table_height() + table_top -= cross_note_height(note_lines, table_scale) + entities.extend( + _cross_table_entities( + drawing_id, quantity_table, table_top, title_label, center_x, table_scale + ) + ) + table_bottom = table_top - cross_table_height(table_scale) # 외곽 테두리: 단면 범위와 표를 함께 감싸고 1 mm 띄운다. pad = CROSS_BORDER_PAD_MM # 그림 범위와 표 중 바깥 — 장 칸에서는 표가 도로 중심이라 좌우가 다를 수 있음(174-1) - frame_left = min(ox + x0 * CROSS_MM, center_x - cross_table_width() / 2.0) - pad - frame_right = max(ox + x1 * CROSS_MM, center_x + cross_table_width() / 2.0) + pad + table_half = cross_table_width(table_scale) / 2.0 + frame_left = min(ox + x0 * CROSS_MM, center_x - table_half) - pad + frame_right = max(ox + x1 * CROSS_MM, center_x + table_half) + pad frame_top = oy + half_height + pad frame_bottom = table_bottom - pad corners = [ diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index f9e8297bd..b5bcda6e2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -13,6 +13,7 @@ from uuid import uuid5 from B05_Profile.B05_Profile_Engine_Sections import cross_key from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + _ENTITY_NS, CROSS_BORDER_PAD_MM, CROSS_MM, CROSS_TABLE_LAYER_ID, @@ -23,7 +24,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( ROCK_LAYER_ID, STRUCTURE_LAYER_ID, TABLE_LINE_COLOR, - _ENTITY_NS, _clip_polyline, _layer, _section_window, @@ -72,6 +72,7 @@ def section_block_size( with_table: bool = True, design: dict[str, Any] | None = None, overlay: tuple[float, float, float, float] | None = None, + table_scale: float = 1.0, ) -> tuple[float, float]: """단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다. @@ -93,13 +94,14 @@ def section_block_size( width = 2.0 * max( (x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM, - cross_table_width() / 2.0 + _BORDER_PAD_MM, + cross_table_width(table_scale) / 2.0 + _BORDER_PAD_MM, ) # 위: 단면 반높이 + 테두리 여유 / 아래: 반높이 + (표 간격 + 표) + 테두리 여유 top = half_height + _BORDER_PAD_MM below = half_height + _BORDER_PAD_MM if with_table: - below += 8.0 + cross_note_height(cross_note_lines(design_spec)) + cross_table_height() + note_h = cross_note_height(cross_note_lines(design_spec), table_scale) + below += 8.0 + note_h + cross_table_height(table_scale) return (width, top + below) @@ -146,6 +148,8 @@ def plan_cross_sheets( "chainages": group, "grid": [cols, rows], "slots": grid_slots(len(group), cols, rows), + # 표 · 글 배율(180-2) — 모든 장 한 값 + "table_scale": float((layout or {}).get("table_scale") or 1.0), } ) start += len(group) @@ -207,6 +211,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> overlay=section.get("overlay"), border=False, table_bottom=cell[1] + CELL_TABLE_PAD_MM, + table_scale=float(sheet.get("table_scale") or 1.0), ) # 칸을 넘는 단면 — 축척 그대로 두고 그 칸을 화면 가이드로 짚음(169-2) frames = [p.get("frame") for p in placed.get("cross_placements") or []] diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index b92b8f5c3..8c5af69fb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -128,16 +128,17 @@ def _cross_table_entities( table_top: float, title_label: str, center_x: float = 0.0, + scale: float = 1.0, ) -> list[dict[str, Any]]: - """횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다. + """횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다 — scale = 표 · 글 배율(180-2). 머리행은 폭을 8등분하고 본문은 11열 가중치를 쓴다 — 격자가 서로 다르므로 두 경계를 합친 열로 표를 만들고 병합으로 원래 칸을 되살린다. 병합 자리에서 선을 끊는 일은 표가 스스로 하므로, 예전처럼 가로선을 손으로 끊지 않는다. """ - width = _CROSS_TABLE_WIDTH - row_h = _CROSS_TABLE_ROW_HEIGHT - font = _CROSS_TABLE_FONT + width = _CROSS_TABLE_WIDTH * scale + row_h = _CROSS_TABLE_ROW_HEIGHT * scale + font = _CROSS_TABLE_FONT * scale left = center_x - width / 2.0 right = center_x + width / 2.0 body_edges = _cross_column_edges(width, center_x) @@ -331,24 +332,24 @@ def cross_note_lines(design: dict[str, Any] | None) -> list[str]: return [line.rstrip() for line in note.strip().splitlines()] -def cross_note_height(lines: list[str]) -> float: - """표 위 글 높이(mm) — 줄 수 × 줄 높이.""" - return _CROSS_NOTE_LINE_HEIGHT * len(lines) +def cross_note_height(lines: list[str], scale: float = 1.0) -> float: + """표 위 글 높이(mm) — 줄 수 × 줄 높이 × 배율.""" + return _CROSS_NOTE_LINE_HEIGHT * scale * len(lines) def _cross_note_entities( - drawing_id: str, lines: list[str], top: float, center_x: float = 0.0 + drawing_id: str, lines: list[str], top: float, center_x: float = 0.0, scale: float = 1.0 ) -> list[dict[str, Any]]: """표 위 글 줄마다 Text — 표 왼쪽 끝 맞춤 · 위에서 아래로(id `{도면}:note:{n}`).""" - left = center_x - _CROSS_TABLE_WIDTH / 2.0 + left = center_x - _CROSS_TABLE_WIDTH * scale / 2.0 return [ _text_entity( f"{drawing_id}:note:{index}", line, left, - top - _CROSS_NOTE_LINE_HEIGHT * (index + 0.5), + top - _CROSS_NOTE_LINE_HEIGHT * scale * (index + 0.5), CROSS_TABLE_LAYER_ID, - _CROSS_TABLE_FONT, + _CROSS_TABLE_FONT * scale, TABLE_LABEL_COLOR, align="left", ) @@ -357,11 +358,11 @@ def _cross_note_entities( ] -def cross_table_width() -> float: +def cross_table_width(scale: float = 1.0) -> float: """수량 산출표 가로 폭(mm).""" - return _CROSS_TABLE_WIDTH + return _CROSS_TABLE_WIDTH * scale -def cross_table_height() -> float: +def cross_table_height(scale: float = 1.0) -> float: """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행.""" - return _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2) + return _CROSS_TABLE_ROW_HEIGHT * scale * (len(_CROSS_LEFT_ROWS) + 2) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 36bc1b6f5..7ffc5ceb4 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -551,10 +551,11 @@ async def invalidate_design_drawing( class CrossLayoutRequest(BaseModel): - """횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3).""" + """횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2).""" default: list[int] = Field(default_factory=lambda: [4, 4]) sheets: dict[str, list[int]] = Field(default_factory=dict) + table_scale: float = 1.0 @router.get("/{project_id}/cross-layout") diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b42a95f53..ac63b7fac 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -104,6 +104,8 @@ CROSS_SHEET_LAYOUT = 5 CROSS_LAYOUT_FILE = "cross_layout.json" CROSS_GRID_DEFAULT = [4, 4] _CROSS_GRID_MAX = 10 +# 표 · 표 위 글 배율(180-2) — 모든 장 한 값 · 기본 1 +CROSS_SCALE_RANGE = (0.5, 2.0) def _cross_grid(value: Any) -> list[int]: @@ -116,13 +118,22 @@ def _cross_grid(value: Any) -> list[int]: return [int(value[0]), int(value[1])] +def _cross_scale(value: Any) -> float: + low, high = CROSS_SCALE_RANGE + if value is None: + return 1.0 + if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high: + raise ValueError(f"표 배율은 {low}~{high} 사이로 적어 주세요.") + return round(float(value), 2) + + def read_cross_layout(project_root: Path) -> dict[str, Any]: - """횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 만.""" + """횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 · 배율 1 만.""" 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": {}} + return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}, "table_scale": 1.0} def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]: @@ -132,6 +143,7 @@ def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]: return { "default": _cross_grid(layout.get("default") or CROSS_GRID_DEFAULT), "sheets": {str(int(key)): _cross_grid(grid) for key, grid in sheets.items()}, + "table_scale": _cross_scale(layout.get("table_scale")), } @@ -449,6 +461,10 @@ def _read_drawing( label = str(manifest_entry.get("label") or drawing_id) if kind == "cross" and manifest_entry.get("layout") != CROSS_SHEET_LAYOUT: saved["stale_layout"] = True # 화면이 [수정] → 새 배치로 다시 만들기를 안내 + elif kind == "cross" and drawing_id.startswith("cross_s"): + scale = read_cross_layout(project_root)["table_scale"] + if manifest_entry.get("table_scale", 1.0) != scale: + saved["stale_scale"] = True # 옛 배율로 확정한 장 — 그대로 두고 안내(180-2) stored_table = manifest_entry.get("quantity_table") table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None return kind, label, saved, True, table @@ -594,6 +610,7 @@ def _store_confirmed_drawing( } if item.kind == "cross": entry["layout"] = CROSS_SHEET_LAYOUT + entry["table_scale"] = read_cross_layout(project_root)["table_scale"] if item.kind == "cross" and isinstance(quantity_table, dict): entry["quantity_table"] = quantity_table if isinstance(quantity_tables, dict) and quantity_tables: diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index c5dd63a11..df2c278d7 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -191,7 +191,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { number ? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }) : null, - (grid) => void save({ default: grid, sheets: {} }), + (grid) => void save({ ...layout, default: grid, sheets: {} }), + { + scale: { + value: layout.table_scale ?? 1, + onApply: (scale) => void save({ ...layout, table_scale: scale }), + }, + }, ), ); }; @@ -343,6 +349,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { "옛 배치로 확정된 장입니다. [수정]을 누르면 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.", "info", ); + } else if (response.drawing.stale_scale) { + showToast( + "옛 표 배율로 확정된 장입니다. [수정]을 누르면 지금 배율로 다시 만듭니다.", + "info", + ); } } catch (error) { cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 83bfd7725..0f214f7c5 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -175,14 +175,24 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); } +/** 표 · 글 배율 범위(180-2) — 서버 `CROSS_SCALE_RANGE` 와 같음. */ +const CROSS_SCALE_MIN = 0.5; +const CROSS_SCALE_MAX = 2; + +/** 배치 칸 줄 뒤 덧칸 — 표 배율(180-2). */ +export interface CrossLayoutExtras { + scale?: { value: number; onApply: (scale: number) => void }; +} + /** - * 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」. + * 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」 + 「표 배율 [1] [적용]」. * `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔. */ export function buildCrossLayoutControls( grid: [number, number], onApply: ((grid: [number, number]) => void) | null, onAllDefault: (grid: [number, number]) => void, + extras: CrossLayoutExtras = {}, ): HTMLElement { const box = document.createElement("div"); box.className = "b07-cross-layout"; @@ -222,6 +232,31 @@ export function buildCrossLayoutControls( action("이 장", "이 장만 이 배치로", onApply), action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), ); + if (extras.scale) { + const { onApply: applyScale } = extras.scale; + const scale = createInputField({ + label: "표 배율", + type: "number", + value: String(extras.scale.value), + min: CROSS_SCALE_MIN, + max: CROSS_SCALE_MAX, + compact: true, + }); + scale.input.setAttribute("step", "0.1"); + const button = createButton({ + label: "배율 적용", + variant: "ghost", + size: "sm", + onClick: () => { + const value = Number(scale.input.value); + if (value >= CROSS_SCALE_MIN && value <= CROSS_SCALE_MAX) applyScale(value); + else + showToast(`표 배율은 ${CROSS_SCALE_MIN}~${CROSS_SCALE_MAX} 사이로 적어 주세요.`, "error"); + }, + }); + button.title = "모든 장의 표 · 표 위 글 크기(기본 1)"; + box.append(scale.root, button); + } return box; } diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs index dc221cc88..c80f3c94c 100644 --- a/resources/tester/helper_153_b07_cross_layout.cjs +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -162,4 +162,30 @@ out.notSheet = { values: inputs().map((n) => n.value), }; +// 180-2 표 배율 칸 — 배치 칸 줄 뒤 · 0.5~2.0 · step 0.1 · [배율 적용] +const scales = []; +const toastStart = toasts.length; +slot.replaceChildren( + buildCrossLayoutControls([4, 4], null, () => {}, { + scale: { value: 1, onApply: (value) => scales.push(value) }, + }), +); +const scaleInput = inputs()[2]; +out.scale = { + labels: byClass(slot, "ui-field__label").map((n) => n.textContent), + input: { + value: scaleInput.value, + min: scaleInput.attrs.min, + max: scaleInput.attrs.max, + step: scaleInput.attrs.step, + }, + buttons: buttons().map((n) => n.textContent), +}; +scaleInput.value = "1.5"; +buttons()[2].click(); +scaleInput.value = "2.5"; +buttons()[2].click(); +out.scale.applied = scales; +out.scale.toasts = toasts.slice(toastStart).map((t) => t[0]); + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_140_b07_cross_grid.py b/resources/tester/test_140_b07_cross_grid.py index f8fb20882..c2905d7e8 100644 --- a/resources/tester/test_140_b07_cross_grid.py +++ b/resources/tester/test_140_b07_cross_grid.py @@ -119,7 +119,11 @@ def _project(tmp_path: Path, count: int) -> tuple[Path, 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": {}} # 파일 없으면 기본 + assert read_cross_layout(root) == { + "default": [4, 4], + "sheets": {}, + "table_scale": 1.0, + } # 파일 없으면 기본 ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)] assert ids == ["cross_s00000m", "cross_s00320m"] # 16 + 4 manifest = _read_manifest(root) diff --git a/resources/tester/test_153_1_b07_cross_layout.py b/resources/tester/test_153_1_b07_cross_layout.py index 58275b487..edc706067 100644 --- a/resources/tester/test_153_1_b07_cross_layout.py +++ b/resources/tester/test_153_1_b07_cross_layout.py @@ -61,3 +61,12 @@ def test_장_아니면_이_장_꺼짐(out): assert not_sheet["disabled"] == [True, False] assert not_sheet["defaults"] == [[4, 4]] assert not_sheet["values"] == ["4", "4"] + + +def test_표_배율_칸(out): + scale = out["scale"] + assert scale["labels"] == ["배치 열", "행", "표 배율"] + assert scale["input"] == {"value": "1", "min": "0.5", "max": "2", "step": "0.1"} + assert scale["buttons"] == ["이 장", "모든 장", "배율 적용"] + assert scale["applied"] == [1.5] + assert scale["toasts"] == ["error"] # 2.5 = 범위 밖 diff --git a/resources/tester/test_180_2_b07_cross_scale.py b/resources/tester/test_180_2_b07_cross_scale.py new file mode 100644 index 000000000..3fc8dec6e --- /dev/null +++ b/resources/tester/test_180_2_b07_cross_scale.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +"""180-2 B07 횡단 장 — 표 · 표 위 글 배율(모든 장 한 값 · 0.5~2.0 · 기본 1). + +못박는 것 + - 배율 1.5 = 표 폭 162 · 높이 60 · 글 줄 높이 × 1.5 · 표 아래선은 칸 아래 + 여백 그대로. + - 그림 아래 끝 = 글(없으면 표) 위 8 mm(간격은 배율 안 탐) · 넓은 표가 칸을 넘으면 overflow_cells. + - 배율 1 = 배율 칸 없는 배치와 같은 엔티티. + - cross_layout.json 이 배율을 지킴 · 범위 밖 = 거절 · 배율만 바꾸면 확정 장 안 풂. + - 지금과 다른 배율로 확정된 장 = 그대로 + stale_scale 안내. +""" + +import json +from pathlib import Path + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, GROUND_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + CELL_TABLE_PAD_MM, + build_cross_sheet, + plan_cross_sheets, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + entities_bbox, + usable_area, + use_project_templates, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + CROSS_SHEET_LAYOUT, + _cross_sheet_plan, + _read_drawing, + _read_manifest, + _write_manifest, + read_cross_layout, + write_cross_layout, +) + +NOTE = {"structure_note": "L형 측구\n집수정 보강"} + + +def _source(chainage: float, lo: int = -5, hi: int = 5) -> dict: + return { + "chainage_m": chainage, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.05 * x} + for x in range(lo, hi + 1) + ], + } + + +def _drawing(layout: dict, designs: list[dict | None]) -> tuple[dict, dict]: + use_project_templates(None) + sources = [_source(i * 20.0) for i in range(len(designs))] + sheet = plan_cross_sheets([s["chainage_m"] for s in sources], layout)[0] + sections = [ + { + "chainage": s["chainage_m"], + "source": s, + "design": design, + "quantity_table": {"ground": 1.0, "planned": 0.5}, + "title": "No.0", + } + for s, design in zip(sources, designs, strict=True) + ] + return sheet, build_cross_sheet(sheet, sections) + + +def _tables(drawing: dict) -> list[dict]: + return [e for e in drawing["entities"] if e["type"] == "Table"] + + +def test_배율_15_표_크기와_쌓기() -> None: + sheet, drawing = _drawing({"default": [2, 2], "table_scale": 1.5}, [NOTE, None]) + assert sheet["table_scale"] == 1.5 + _w, h = usable_area() + tables = _tables(drawing) + for table, slot in zip(tables, sheet["slots"], strict=True): + shape = table["shapeData"] + assert sum(shape["columnWidths"]) == pytest.approx(162.0) + assert sum(shape["rowHeights"]) == pytest.approx(60.0) + bottom = shape["origin"]["y"] - sum(shape["rowHeights"]) + assert bottom == pytest.approx(slot[1] - h / 4 + CELL_TABLE_PAD_MM) # 표 아래선 그대로 + grounds = [ + entities_bbox([e]) for e in drawing["entities"] if e.get("layerId") == GROUND_LAYER_ID + ] + # 글 2 줄 × 3.0 × 1.5 = 9 mm · 간격 8 mm 는 배율 안 탐 + tops = [t["shapeData"]["origin"]["y"] for t in tables] + assert grounds[0][1] == pytest.approx(tops[0] + 9.0 + 8.0) + assert grounds[1][1] == pytest.approx(tops[1] + 8.0) + + +def test_배율_1_은_지금과_같음() -> None: + _s, plain = _drawing({"default": [2, 2]}, [NOTE, None]) + _s, one = _drawing({"default": [2, 2], "table_scale": 1.0}, [NOTE, None]) + assert one["entities"] == plain["entities"] + assert sum(_tables(one)[0]["shapeData"]["columnWidths"]) == pytest.approx(108.0) + + +def test_넓은_표_넘침() -> None: + w, _h = usable_area() + # 4 × 4 칸 폭 192.5 — 표 108 × 2.0 = 216 이 칸을 넘음 + _s, drawing = _drawing({"default": [4, 4], "table_scale": 2.0}, [None]) + assert w / 4 < 216.0 + assert len(drawing["overflow_cells"]) == 1 + _s, drawing = _drawing({"default": [4, 4], "table_scale": 1.0}, [None]) + assert drawing["overflow_cells"] == [] + + +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))), "utf-8" + ) + longitudinal_path.write_text(json.dumps({"stations": stations}), "utf-8") + return root, longitudinal_path + + +def test_배치_파일_배율_저장_범위(tmp_path: Path) -> None: + root, longitudinal_path = _project(tmp_path, 5) + ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)] + manifest = _read_manifest(root) + manifest["drawings"] = {i: {"confirmed": True} for i in ids} + _write_manifest(root, manifest) + # 배율만 바꿈 — 담긴 측점 그대로라 확정 장 안 풂 + assert ( + write_cross_layout(root, longitudinal_path, {"default": [4, 4], "table_scale": 1.3}) == [] + ) + assert read_cross_layout(root)["table_scale"] == 1.3 + assert _read_manifest(root)["drawings"][ids[0]]["confirmed"] is True + assert _cross_sheet_plan(root, longitudinal_path)[0]["table_scale"] == 1.3 + for bad in (0.4, 2.1, "1.2", True): + with pytest.raises(ValueError): + write_cross_layout(root, longitudinal_path, {"default": [4, 4], "table_scale": bad}) + assert read_cross_layout(root)["table_scale"] == 1.3 + + +def test_옛_배율_확정_장_안내(tmp_path: Path) -> None: + stage = tmp_path / "B07_DesignDetail" + (stage / "drawings").mkdir(parents=True) + saved = {"format": DRAWING_FORMAT, "entities": [], "layers": []} + (stage / "drawings" / "cross_s00000m.json").write_text(json.dumps(saved), encoding="utf-8") + entry = {"kind": "cross", "label": "1장", "confirmed": True, "layout": CROSS_SHEET_LAYOUT} + manifest = {"drawings": {"cross_s00000m": entry}} + (stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + def read() -> dict: + _k, _l, drawing, confirmed, _t = _read_drawing( + tmp_path, tmp_path / "x.json", "cross_s00000m" + ) + assert confirmed + return drawing + + assert "stale_scale" not in read() # 배율 적힘 없음 = 1 · 지금도 1 + (stage / "cross_layout.json").write_text( + json.dumps({"default": [4, 4], "sheets": {}, "table_scale": 1.5}), encoding="utf-8" + ) + assert read().get("stale_scale") is True + entry["table_scale"] = 1.5 + (stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + assert "stale_scale" not in read()