diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index c7e856856..f9cbf42d2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -36,6 +36,8 @@ export interface CadDrawing { x0: number; x1: number; }[]; + /** 횡단 장 — 옛 배치(칸 통일)로 확정된 저장본. [수정]을 누르면 새 배치로 다시 만든다. */ + stale_layout?: boolean; layers: { id: string; name: string; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 735e8371c..f1f9a5eca 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -450,6 +450,10 @@ def _section_window( if left is None or right is None: xs = [x for x, _y in ground] or [-6.0, 6.0] left, right = min(xs), max(xs) + # 여유는 선이 있는 데까지만 — 원지반 자료가 짧으면 빈 여유가 테두리를 밀어낸다(68-1). + drawn = [x for x, _y in ground] + [x for x, _y in design] + if drawn: + return (max(left - margin_m, min(drawn)), min(right + margin_m, max(drawn))) return (left - margin_m, right + margin_m) @@ -483,7 +487,6 @@ def build_cross_drawing( design_elevation_m: float | None = None, frame: dict[str, float] | None = None, origin: tuple[float, float] = (0.0, 0.0), - cell_frame: tuple[float, float, float, float] | None = None, ) -> dict[str, Any]: """횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다. @@ -491,10 +494,6 @@ def build_cross_drawing( 설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의 bbox 중심을 0에 둔다(측점마다 화면 중앙 정렬). design_elevation_m는 현재 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다. - - cell_frame(왼쪽, 아래, 오른쪽, 위 — 종이 mm)을 주면 테두리를 그 칸에 맞춰 - 그린다. 장 배치에서 한 장 안의 칸을 같은 크기로 통일할 때 쓴다(2026-09-04 - 사용자 확정 — 축척 1/100은 그대로, 칸만 통일). """ ox, oy = origin raw_ground = points_from_samples(source.get("samples", []), "offset_m") @@ -556,15 +555,12 @@ def build_cross_drawing( ) table_bottom = table_top - cross_table_height() - # 외곽 테두리: 단면 범위와 표를 함께 감싼다. 칸 크기를 받았으면 그 칸에 맞춘다. - if cell_frame is not None: - frame_left, frame_bottom, frame_right, frame_top = cell_frame - else: - pad = CROSS_BORDER_PAD_MM - frame_x = max((x1 - x0) / 2.0 * CROSS_MM + pad, cross_table_width() / 2.0 + pad) - frame_left, frame_right = center_x - frame_x, center_x + frame_x - frame_top = oy + half_height + pad - frame_bottom = table_bottom - pad + # 외곽 테두리: 단면 범위와 표를 함께 감싸고 1 mm 띄운다. + pad = CROSS_BORDER_PAD_MM + frame_x = max((x1 - x0) / 2.0 * CROSS_MM + pad, cross_table_width() / 2.0 + pad) + frame_left, frame_right = center_x - frame_x, center_x + frame_x + frame_top = oy + half_height + pad + frame_bottom = table_bottom - pad corners = [ (frame_left, frame_bottom), (frame_right, frame_bottom), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 73e91308c..0784e2203 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -42,9 +42,7 @@ from config.config_system import DRAWING_SCALE_CROSS # 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다. CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?") -# 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. -# 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거). -_BLOCK_GAP_MM = 0.0 +# 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. 블록은 서로 맞닿게 쌓는다. _BORDER_PAD_MM = CROSS_BORDER_PAD_MM @@ -61,18 +59,25 @@ def _design_points(design_line: list[Any] | None) -> list[tuple[float, float]]: def section_block_size( - source: dict[str, Any], design_line: list[Any] | None, with_table: bool = True + source: dict[str, Any], + design_line: list[Any] | None, + with_table: bool = True, + design: dict[str, Any] | None = None, ) -> tuple[float, float]: """단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다. build_cross_drawing의 테두리 계산과 같은 규칙을 쓴다: 가로는 그리는 범위와 - 수량표 중 넓은 쪽, 세로는 단면 높이 + 수량표. + 수량표 중 넓은 쪽, 세로는 단면 높이(암 경계선 포함) + 수량표. """ + design_spec = design if isinstance(design, dict) else None ground = points_from_samples(source.get("samples", []), "offset_m") design = _design_points(design_line) x0, x1 = _section_window(ground, design) ground_clipped = _clip_polyline(ground, x0, x1) ys = [y for _x, y in ground_clipped] + [y for x, y in design if x0 <= x <= x1] + rock_offset = (design_spec or {}).get("rock_boundary_offset_m") + if isinstance(rock_offset, (int, float)): + ys.extend(y + float(rock_offset) for _x, y in ground_clipped) half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM width = 2.0 * max( @@ -87,20 +92,34 @@ def section_block_size( return (width, top + below) -def _grid_for(group: list[tuple[int, float, float]]) -> tuple[float, float, int, int]: - """한 장에 담을 블록 묶음의 (칸폭, 칸높이, 열수, 행수) — 칸은 그 장 최대 블록 기준.""" +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 을 한 장에 담을 수 있나 — 열 폭 합이 가로를, 열마다 높이 합이 세로를 넘지 않는다.""" usable_w, usable_h = usable_area() - cell_w = max(width for _c, width, _h in group) + _BLOCK_GAP_MM - cell_h = max(height for _c, _w, height in group) + _BLOCK_GAP_MM - return cell_w, cell_h, int(usable_w // cell_w), int(usable_h // cell_h) + 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 -def _max_take(blocks: list[tuple[int, float, float]], start: int) -> int: - """blocks[start:] 를 한 장에 담을 수 있는 최대 개수(칸 통일 기준).""" +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): - _cw, _ch, columns, rows = _grid_for(blocks[start : start + take]) - if columns * rows < take: + if not _fits(blocks[start : start + take]): break limit = take return limit @@ -134,38 +153,37 @@ def _sheet_breaks(blocks: list[tuple[int, float, float]]) -> list[int]: return breaks -def _slots(cell_w: float, cell_h: float, rows: int, count: int) -> list[list[float]]: - """칸의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열. +def _slots(group: list[tuple[int | float, float, float]]) -> list[list[float]]: + """블록의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 쌓고, 열이 차면 오른쪽 열. - 칸이 모두 같은 크기이므로 격자 좌표만 계산하면 된다(2026-08-29 사용자: 채우는 - 순서는 좌하단부터 열 우선). + 블록은 자기 크기 그대로 맞닿게 쌓는다 — 칸을 장 최대 블록으로 통일하면 작은 + 단면의 테두리가 그림에서 수십 mm 떨어졌다(68-1). 열 폭은 그 열 최대 블록 폭이고 + 블록은 열 가운데에 선다. """ usable_w, usable_h = usable_area() - slots: list[list[float]] = [] - for index in range(count): - column, row = divmod(index, rows) - slots.append( - [ - -usable_w / 2.0 + column * cell_w + cell_w / 2.0, - -usable_h / 2.0 + row * cell_h, - ] - ) + 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/100 고정 — 안 들어가면 장을 나눌 뿐 줄이지 않는다(지식DB - 「설계제원_총괄」 측량·도면 기준). 장에 담기는 측점 수는 세트마다 다르다 - (2026-09-04 사용자 확정). + 블록은 자기 크기(그림 + 테두리 1 mm)로 열마다 쌓는다. 작성 척도는 1/100 고정 — 안 + 들어가면 장을 나눌 뿐 줄이지 않는다(지식DB 「설계제원_총괄」 측량·도면 기준). 장에 + 담기는 측점 수는 세트마다 다르다(2026-09-04 사용자 확정). """ sheets: list[dict[str, Any]] = [] start = 0 for count in _sheet_breaks(blocks): group = blocks[start : start + count] - cell_w, cell_h, _columns, rows = _grid_for(group) chainages = [chainage for chainage, _w, _h in group] sheets.append( { @@ -175,10 +193,7 @@ def plan_cross_sheets(blocks: list[tuple[int | float, float, float]]) -> list[di "id": f"cross_s{cross_key(chainages[0])}m", "number": len(sheets) + 1, "chainages": chainages, - "rows": max(rows, 1), - "cell_width": cell_w, - "cell_height": cell_h, - "slots": _slots(cell_w, cell_h, max(rows, 1), count), + "slots": _slots(group), } ) start += count @@ -190,8 +205,6 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> entities: list[dict[str, Any]] = [] placements: list[dict[str, Any]] = [] slots = sheet.get("slots") or [] - cell_w = float(sheet.get("cell_width") or 0.0) - cell_h = float(sheet.get("cell_height") or 0.0) for index, section in enumerate(sections): if index >= len(slots): @@ -211,22 +224,9 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> continue min_x, min_y, max_x, max_y = bbox center_x, bottom_y = slots[index] - # 2) 가로는 칸 가운데, 세로는 칸 아래 변에 맞춰 원점을 옮겨 다시 만든다. - # 아래를 맞춰야 같은 행의 수량표가 한 줄로 선다. - origin = ( - center_x - (min_x + max_x) / 2.0, - bottom_y + _BLOCK_GAP_MM / 2.0 - min_y, - ) - # 3) 테두리는 칸 크기로 통일한다 — 단면 크기와 무관하게 한 장 안에서 같은 크기. - cell_frame = None - if cell_w > 0.0 and cell_h > 0.0: - half = (cell_w - _BLOCK_GAP_MM) / 2.0 - cell_frame = ( - center_x - half, - bottom_y + _BLOCK_GAP_MM / 2.0, - center_x + half, - bottom_y + cell_h - _BLOCK_GAP_MM / 2.0, - ) + # 2) 가로는 칸 가운데, 세로는 칸 아래 변에 테두리 아래 변을 맞춰 다시 만든다. + # 테두리는 자기 그림 + 1 mm — 칸 크기로 넓히지 않는다(68-1). + origin = (center_x - (min_x + max_x) / 2.0, bottom_y - min_y) placed = build_cross_drawing( section["source"], seed_id, @@ -235,7 +235,6 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> section.get("quantity_table"), section.get("title", ""), origin=origin, - cell_frame=cell_frame, ) entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or []) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index bc0640286..9edc65c74 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -103,6 +103,8 @@ WATERSHED_ID = "watershed" COVER_ID = "cover" # 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장. CROSS_STANDARD_ID = "cross_standard" +# 횡단 장 배치 판 — 단면마다 자기 그림 + 1 mm 박스(68-1). 이 판 전에 확정한 장은 옛 배치다. +CROSS_SHEET_LAYOUT = 2 def _drawing_list( @@ -229,7 +231,7 @@ def _cross_sheet_plan( 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) - width, height = section_block_size(source, design_line) + width, height = section_block_size(source, design_line, design=design) blocks.append((chainage, width, height)) return plan_cross_sheets(blocks) @@ -413,6 +415,8 @@ def _read_drawing( else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) + if kind == "cross" and manifest_entry.get("layout") != CROSS_SHEET_LAYOUT: + saved["stale_layout"] = True # 화면이 [수정] → 새 배치로 다시 만들기를 안내 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 @@ -585,6 +589,8 @@ def _store_confirmed_drawing( "confirmed": True, "file": f"drawings/{item.id}.json", } + if item.kind == "cross": + entry["layout"] = CROSS_SHEET_LAYOUT 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 8625e4f5f..a26e8b74f 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -272,6 +272,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { applyConfirmButtonState(); updateInfoPanel(drawing, response); cad.load(response.drawing, buildMeta(drawing, response, index)); + if (response.drawing.stale_layout) { + showToast( + "옛 배치로 확정된 장입니다. [수정]을 누르면 박스를 그림에 1 mm로 붙인 새 배치로 다시 만듭니다.", + "info", + ); + } } catch (error) { cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); if (currentDrawing) highlightActive(currentDrawing.id); diff --git a/resources/tester/test_b07_cross_sheet_pad.py b/resources/tester/test_b07_cross_sheet_pad.py index 68e392445..dcf0cb1f4 100644 --- a/resources/tester/test_b07_cross_sheet_pad.py +++ b/resources/tester/test_b07_cross_sheet_pad.py @@ -32,3 +32,120 @@ def test_frame_hugs_section_by_one_mm() -> None: width, height = section_block_size(_SOURCE, None, with_table=False) assert height == pytest.approx(f_max_y - f_min_y) assert width == pytest.approx(f_max_x - f_min_x) + + +# ── 68-1 장 배치: 단면마다 자기 박스(칸 통일 없음) ────────────────────────────── + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import CROSS_SECTION_MARGIN_M # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( # noqa: E402 + build_cross_sheet, + plan_cross_sheets, +) + + +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 _box(entity: dict) -> tuple[float, float, float, float] | None: + if entity.get("type") == "Table": + shape = entity["shapeData"] + origin = shape["origin"] + return ( + origin["x"], + origin["y"] - sum(shape["rowHeights"]), + origin["x"] + sum(shape["columnWidths"]), + origin["y"], + ) + return entities_bbox([entity]) + + +_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) + assert len(sheets) == 1 + sections = [ + {"chainage": s["chainage_m"], "source": s, "quantity_table": _TABLE, "title": ""} + for s in sources + ] + drawing = build_cross_sheet(sheets[0], sections) + inner = [_box(e) for e in drawing["entities"] if e.get("layerId") != FRAME_LAYER_ID] + frames = [p["frame"] for p in drawing["cross_placements"]] + sizes = {(round(f[2] - f[0], 1), round(f[3] - f[1], 1)) for f in frames} + assert len(sizes) == 3 # 칸 통일 없음 — 박스 크기가 단면마다 다르다 + for left, bottom, right, top in frames: + mine = [ + b + for b in inner + if b + and b[0] >= left - 1e-6 + and b[2] <= right + 1e-6 + and b[1] >= bottom - 1e-6 + and b[3] <= top + 1e-6 + ] + assert min(b[0] for b in mine) - left == pytest.approx(1.0) + assert right - max(b[2] for b in mine) == pytest.approx(1.0) + assert min(b[1] for b in mine) - bottom == pytest.approx(1.0) + 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_window_stops_where_lines_end() -> None: + """원지반 자료가 여유(1.5 m)보다 짧으면 범위를 선 끝에서 멈춘다 — 빈 여유가 박스를 밀지 않음.""" + source = _source(0.0, 0.1, 6) + design_line = [ + {"offset_m": -6.0, "elevation_m": 99.4}, + {"offset_m": -5.5, "elevation_m": 98.0}, + {"offset_m": 5.5, "elevation_m": 98.0}, + {"offset_m": 6.0, "elevation_m": 100.6}, + ] + drawing = build_cross_drawing(source, "cross_00000m", design_line, None, None, "") + placement = drawing["cross_placements"][0] + assert placement["x0"] == pytest.approx(-6.0) + assert placement["x1"] == pytest.approx(6.0) + assert CROSS_SECTION_MARGIN_M == 1.5 + + +def test_old_confirmed_cross_sheet_is_flagged(tmp_path) -> None: + """68-1 전에 확정한 횡단 장은 저장본을 그대로 보이되 「옛 배치」 표시를 단다.""" + import json + + from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT + from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + CROSS_SHEET_LAYOUT, + _read_drawing, + ) + + 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} + manifest = {"drawings": {"cross_s00000m": entry}} + (stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + _k, _l, drawing, confirmed, _t = _read_drawing(tmp_path, tmp_path / "x.json", "cross_s00000m") + assert confirmed and drawing.get("stale_layout") is True + + entry["layout"] = CROSS_SHEET_LAYOUT + (stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + _k, _l, drawing, confirmed, _t = _read_drawing(tmp_path, tmp_path / "x.json", "cross_s00000m") + assert confirmed and "stale_layout" not in drawing