From f4889352f942bd9f58bcb510913457738d278b2a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:01:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09-print):=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EB=8F=84=EB=A9=B4=20PDF?= =?UTF-8?q?=20=E2=80=94=20B07=20=EB=8F=84=EB=A9=B4=20+=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=EB=8F=84=EA=B0=81=20=ED=81=AC=EA=B8=B0=20=EC=AA=BD=20?= =?UTF-8?q?=C2=B7=20=EC=88=A8=EA=B8=B4=20=EC=B8=B5=20=EB=BA=8C=20=C2=B7=20?= =?UTF-8?q?=EB=B2=A1=ED=84=B0=20=C2=B7=20=ED=95=9C=EA=B8=80=20(302-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 길 = GET /api/projects/{id}/print/drawings · POST /api/projects/{id}/print/drawings-pdf {ids?} (draft 라우터 · 로그인 · 회사 · 프로젝트 권한 스스로) - 차례 = B07 도면번호 → 구조물 조합 도면(도번 차례 · 도번마다 한 번 · 더한장) - B07 = B07 단건 조회 그대로(확정본 · 미확정 조립) · 미확정 횡단 장 구조물 = 화면 TS 를 서버 Node 로(번들 tmp · package.json 안 건드림) - 쪽 = 도각 양식 실치수(A1 840×594) · 색 = openwebcad 밝은 바탕 규칙 · 글 = 맑은 고딕 심음(subset) · 그림 = data URL 넣음 · 못 넣은 수 = 응답 머리 - 화면 쪽 = fetchDrawingList · fetchDrawingsPdf(약속 꼴) · skippedImagesOf - requirements = PyMuPDF · ezdxf Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../draft/B09_Draft_Print_Drawings.ts | 59 +++ .../draft/B09_Draft_Print_Drawings_Node.ts | 34 ++ .../draft/B09_Draft_Print_Drawings_Pdf.py | 337 ++++++++++++++++++ .../draft/B09_Draft_Print_Drawings_Router.py | 99 +++++ .../draft/B09_Draft_Print_Drawings_Source.py | 240 +++++++++++++ requirements.txt | 2 + resources/tester/test_302_2_print_drawings.py | 273 ++++++++++++++ 7 files changed, 1044 insertions(+) create mode 100644 B09_Estimation/draft/B09_Draft_Print_Drawings.ts create mode 100644 B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts create mode 100644 B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py create mode 100644 B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py create mode 100644 B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py create mode 100644 resources/tester/test_302_2_print_drawings.py diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings.ts b/B09_Estimation/draft/B09_Draft_Print_Drawings.ts new file mode 100644 index 000000000..7f3907355 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * B09_Draft_Print_Drawings.ts + * 인쇄 미리보기 도면 영역(PLAN 302-2) — 서버가 만든 목록 · PDF 를 그대로 받음(보는 것 = 받는 것). + * 차례 = B07 도면번호 → 구조물 조합 도면(도번마다 한 번) · 서버 `B09_Draft_Print_Drawings_Router.py` + * PDF 머리 X-Aislo-Skipped-Images = 못 넣은 그림 {id: 수} → `skippedImagesOf(blob)` + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DrawingItem, FetchDrawingList, FetchDrawingsPdf } from "./B09_Draft_Print_Types"; + +/** 도면 한 장 — 약속 꼴(id · name = label) + 서버가 주는 칸 */ +export interface PrintDrawingItem extends DrawingItem { + source: "b07" | "structure"; + kind: string; + label: string; + number: string; + confirmed: boolean; + paper: string | null; + /** PDF 를 만들어야 셈 — 목록에서는 null */ + images_skipped: number | null; +} + +async function failure(response: Response): Promise { + const body = (await response.json().catch(() => null)) as { message?: string } | null; + return new Error(body?.message ?? `HTTP ${response.status}`); +} + +export const fetchDrawingList: FetchDrawingList = async (projectId) => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/print/drawings`, { + credentials: "include", + }); + if (!response.ok) throw await failure(response); + const items = (await response.json()) as Omit[]; + return items.map((item): PrintDrawingItem => ({ ...item, name: item.label })); +}; + +const skipped = new WeakMap>(); + +export const fetchDrawingsPdf: FetchDrawingsPdf = async (projectId, ids) => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/print/drawings-pdf`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(ids ? { ids } : {}), + }); + if (!response.ok) throw await failure(response); + const blob = await response.blob(); + try { + skipped.set(blob, JSON.parse(response.headers.get("X-Aislo-Skipped-Images") ?? "{}")); + } catch { + /* 머리가 깨졌으면 빠진 그림 없음으로 */ + } + return blob; +}; + +/** 그 PDF 에서 못 넣은 그림 {도면 id: 수} — 없으면 빈 꼴 */ +export function skippedImagesOf(blob: Blob): Record { + return skipped.get(blob) ?? {}; +} diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts b/B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts new file mode 100644 index 000000000..c3b936efe --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts @@ -0,0 +1,34 @@ +/* ============================================================================= + * B09_Draft_Print_Drawings_Node.ts + * 인쇄 미리보기 도면(PLAN 302-2) — 미확정 횡단 장에 구조물을 얹는 Node 진입점(CLAUDE.md 5장 ②). + * + * B07 화면이 미확정 장을 열 때 얹는 것과 **같은 코드**(`placeStructureEntities`)를 서버에서 돌린다. + * 입력 상세 = 정본(`get_section_detail`) — 화면 캐시의 저장 안 한 편집은 안 실림(인쇄 = 저장본). + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { detail: 종횡단 상세, drawings: { [도면 id]: CAD 도면 } } + * 출력 { drawings: { [도면 id]: 구조물을 얹은 CAD 도면 } } + * 끝 코드: 0 성공 / 2 인자 오류 + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import "../../B07_DesignDetail/B07_DesignDetail_Node_Dom"; // 최소 DOM — 그리기 코드보다 먼저 +import { placeStructureEntities } from "../../B07_DesignDetail/B07_DesignDetail_Cross_Overlay"; + +type Drawing = Parameters[1]; + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} + +const input = JSON.parse(readFileSync(inputPath, "utf8")) as { + detail?: SectionDetailResponse; + drawings?: Record; +}; +const sections = input.detail?.cross_sections ?? []; +const drawings = input.drawings ?? {}; +for (const drawing of Object.values(drawings)) placeStructureEntities(sections, drawing); +writeFileSync(outputPath, JSON.stringify({ drawings }), "utf8"); diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py new file mode 100644 index 000000000..117ccfb53 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py @@ -0,0 +1,337 @@ +"""인쇄 미리보기 도면(PLAN 302-2) — CAD 도면(openwebcad JSON) 한 장 → PDF 한 쪽(벡터). + +쪽 = 도각 — 도각 층 범위를 그 양식 실치수(A1 840×594 · 회사 양식이면 그 크기) 종이에 맞춤 · +도각이 없으면 그림 범위 1:1. 숨긴 층 · 동결 층은 뺌(화면에 안 보이는 것 = 안 나감). + +그리기 규칙 = openwebcad 화면(밝은 바탕)과 같게: + 색 = `theme.ts` `paintColor` 밝은 바탕 짝(자동색 → 검정 · 휘도 0.58 넘는 색 → 0.3 으로 누름) + 글 = 기준점 세로 가운데(textBaseline middle) · 정렬 left/center/right(기본 center) · + 방향 벡터 회전 + 호 = 세계 좌표 반시계(counterClockwise 거짓이면 시계) · 점 = 반지름 5 원 · + 그림 = 칸 안 비율 지켜 가운데 + 표 = `B07 Frame_Export.table_parts`(openwebcad table-geometry 거울) 그대로 +한글 = 시스템 TTF(맑은 고딕 → Noto Sans KR → 나눔고딕) 심음 · 끝에 문서가 글꼴을 줄임(subset). +""" + +from __future__ import annotations + +import base64 +import math +import re +from functools import lru_cache +from pathlib import Path +from typing import Any + +import pymupdf + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import table_parts +from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, template_entities + +PT_PER_MM = 72 / 25.4 +#: 화면 선 굵기(px) → 종이(pt) — ponytail: 고정 배율, 출력물 보고 고칠 손잡이 +LINE_PT_PER_PX = 0.5 +DASH_PT_PER_PX = 1.0 +POINT_RADIUS = 5.0 +DEFAULT_FONT_MM = 3.0 +#: 구조물 조합 도면에 얹는 도각 층(M02 `STRUCTURE_FRAME_LAYER`) +STRUCTURE_FRAME_LAYER = "m02-structure-frame" +#: 쪽을 잡는 도각 층 — 앞이 먼저 +#: (구조물 도면은 불러온 DXF 도각 `b08-frame` 이 그림 안에 있어도 얹은 도각) +FRAME_LAYERS = (STRUCTURE_FRAME_LAYER, FRAME_LAYER_ID) +PAPERS = {"A1": (840.0, 594.0), "A3": (420.0, 297.0)} + +FONT_FILES = ( + "C:/Windows/Fonts/malgun.ttf", + "C:/Windows/Fonts/NotoSansKR-VF.ttf", + "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", +) +_AUTO_COLORS = {"#000000", "#ffffff", "#f5f7fa", "black", "white"} + + +@lru_cache(maxsize=1) +def _font() -> pymupdf.Font: + for path in FONT_FILES: + if Path(path).is_file(): + return pymupdf.Font(fontfile=path) + raise RuntimeError("한글 글꼴(맑은 고딕 · Noto Sans KR · 나눔고딕)을 찾지 못했습니다.") + + +def _hex(color: str) -> str: + value = color.strip().lower() + short = re.fullmatch(r"#([0-9a-f])([0-9a-f])([0-9a-f])", value) + return "#" + "".join(c * 2 for c in short.groups()) if short else value + + +def paint_color(color: Any) -> tuple[float, float, float]: + """`theme.ts` `paintColor`(밝은 바탕) 짝 — 거울 시험 `test_302_2_print_drawings`.""" + value = _hex(str(color or "#ffffff")) + if value in _AUTO_COLORS or not re.fullmatch(r"#[0-9a-f]{6}", value): + return (0.0, 0.0, 0.0) + r, g, b = (int(value[i : i + 2], 16) for i in (1, 3, 5)) + luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 + if luminance > 0.58: + scale = 0.3 / luminance + r, g, b = (round(min(255, v * scale)) for v in (r, g, b)) + return (r / 255, g / 255, b / 255) + + +def _xy(point: Any) -> tuple[float, float] | None: + if isinstance(point, dict) and isinstance(point.get("x"), (int, float)): + return float(point["x"]), float(point["y"]) + return None + + +def hidden_layers(drawing: dict[str, Any]) -> set[str]: + return { + str(layer.get("id")) + for layer in drawing.get("layers") or [] + if isinstance(layer, dict) and (layer.get("isVisible") is False or layer.get("isFrozen")) + } + + +def visible_entities(drawing: dict[str, Any]) -> list[dict[str, Any]]: + """숨긴 · 동결 층 개체를 (자식까지) 뺀 목록.""" + hidden = hidden_layers(drawing) + + def keep(entity: Any) -> dict[str, Any] | None: + if not isinstance(entity, dict) or entity.get("layerId") in hidden: + return None + children = entity.get("children") + if not children: + return entity + return {**entity, "children": [c for c in map(keep, children) if c]} + + return [e for e in map(keep, drawing.get("entities") or []) if e] + + +def paper_name(width: float, height: float) -> str: + for name, (w, h) in PAPERS.items(): + if abs(width - w) < 5 and abs(height - h) < 5: + return name + return f"{width:.0f}×{height:.0f}mm" + + +def page_frame(entities: list[dict[str, Any]]) -> tuple[tuple[float, ...], tuple[float, float]]: + """(세계 범위 x0, y0, x1, y1 · 종이 mm (가로, 세로)) — 도각 층 범위 → 양식 실치수 종이.""" + for layer in FRAME_LAYERS: + frame = entities_bbox([e for e in entities if e.get("layerId") == layer]) + if not frame: + continue + source = entities_bbox(template_entities()) + if source: + width, height = source[2] - source[0], source[3] - source[1] + return frame, (width, height) + box = entities_bbox(entities) or (0.0, 0.0, 840.0, 594.0) + return box, (max(box[2] - box[0], 1.0), max(box[3] - box[1], 1.0)) + + +_KAPPA = 0.5523 # 원 = 베지어 넷 + + +def _g(value: float) -> str: + return f"{value:.3f}".rstrip("0").rstrip(".") + + +class _Painter: + """세계 좌표(mm · y 위로) → PDF 그리기 명령(pt). + + 같은 선 모양 = 한 묶음 · 글 = 색마다 TextWriter. + + 선은 명령 글을 목록에 모아 한 번에 붙임 — + pymupdf Shape 를 개체마다 부르면 장 하나 수만 줄에 수십 초. + """ + + def __init__(self, page: pymupdf.Page, world: tuple[float, ...], font: pymupdf.Font): + self.page = page + self.height = page.rect.height + self.x0, self.y1 = world[0], world[3] + width, height = max(world[2] - world[0], 1e-9), max(world[3] - world[1], 1e-9) + self.k = min(page.rect.width / width, page.rect.height / height) + # 도각 비율이 종이와 다르면 가운데 + self.ox = (page.rect.width - width * self.k) / 2 + self.oy = (page.rect.height - height * self.k) / 2 + self.font = font + self.parts: list[str] = [] + self.ops: list[str] = [] + self.style: tuple | None = None + self.writers: dict[tuple, pymupdf.TextWriter] = {} + self.turned: list[tuple[pymupdf.TextWriter, pymupdf.Point, float]] = [] + self.skipped = 0 + + def p(self, x: float, y: float) -> pymupdf.Point: + """쪽 좌표(y 아래로) — 글 · 그림 자리.""" + return pymupdf.Point((x - self.x0) * self.k + self.ox, (self.y1 - y) * self.k + self.oy) + + def q(self, x: float, y: float) -> str: + """PDF 좌표(y 위로) 글 — 선 명령.""" + page_y = (self.y1 - y) * self.k + self.oy + return f"{_g((x - self.x0) * self.k + self.ox)} {_g(self.height - page_y)}" + + def stroke(self, entity: dict[str, Any], fill: tuple | None = None) -> None: + width = max(float(entity.get("lineWidth") or 1), 0.1) * LINE_PT_PER_PX + dash = tuple(float(d) * DASH_PT_PER_PX for d in entity.get("lineDash") or []) + style = (paint_color(entity.get("lineColor")), width, dash, fill) + if style != self.style: + self.flush() + self.style = style + + def flush(self) -> None: + if self.style is None or not self.ops: + return + (r, g, b), width, dash, fill = self.style + head = f"q 1 J 1 j {_g(r)} {_g(g)} {_g(b)} RG {_g(width)} w [{' '.join(map(_g, dash))}] 0 d" + if fill: + head += f" {_g(fill[0])} {_g(fill[1])} {_g(fill[2])} rg" + self.parts.append(f"{head}\n{''.join(self.ops)}{'B' if fill else 'S'} Q\n") + self.ops = [] + + def line(self, points: list[tuple[float, float]]) -> None: + self.ops.append( + f"{self.q(*points[0])} m " + " ".join(f"{self.q(*xy)} l" for xy in points[1:]) + "\n" + ) + + def circle(self, center: tuple[float, float], radius: float) -> None: + cx, cy = center + c = radius * _KAPPA + q = self.q + self.ops.append( + f"{q(cx + radius, cy)} m " + f"{q(cx + radius, cy + c)} {q(cx + c, cy + radius)} {q(cx, cy + radius)} c " + f"{q(cx - c, cy + radius)} {q(cx - radius, cy + c)} {q(cx - radius, cy)} c " + f"{q(cx - radius, cy - c)} {q(cx - c, cy - radius)} {q(cx, cy - radius)} c " + f"{q(cx + c, cy - radius)} {q(cx + radius, cy - c)} {q(cx + radius, cy)} c\n" + ) + + def text(self, label: str, base: tuple[float, float], options: dict[str, Any]) -> None: + if not label.strip(): + return + size = float(options.get("fontSize") or DEFAULT_FONT_MM) * self.k + width = self.font.text_length(label, fontsize=size) + align = options.get("textAlign") or "center" + shift = {"left": 0.0, "right": width}.get(align, width / 2) + direction = _xy(options.get("textDirection")) or (1.0, 0.0) + angle = math.degrees(math.atan2(direction[1], direction[0])) + anchor = self.p(*base) + color = paint_color(options.get("textColor")) + # 기준점 = 세로 가운데 → 글자 바닥선은 (올림 + 내림) / 2 만큼 아래 + drop = (self.font.ascender + self.font.descender) / 2 * size + if abs(angle) > 1e-6: + writer = pymupdf.TextWriter(self.page.rect, color=color) + self.turned.append((writer, anchor, angle)) + else: + writer = self.writers.setdefault(color, pymupdf.TextWriter(self.page.rect, color=color)) + writer.append(anchor + (-shift, drop), label, font=self.font, fontsize=size) + + def finish(self) -> None: + """선 → 글 차례로 붙임(글이 선 위).""" + self.flush() + if self.parts: + shape = self.page.new_shape() + shape.totalcont = "".join(self.parts) + shape.commit() + for writer in self.writers.values(): + writer.write_text(self.page) + for writer, anchor, angle in self.turned: + writer.write_text(self.page, morph=(anchor, pymupdf.Matrix(-angle))) + + def image(self, entity: dict[str, Any]) -> None: + shape = entity.get("shapeData") or {} + points = [xy for v in shape.get("points") or [] if (xy := _xy(v))] + data = str(shape.get("imageData") or "") + found = re.match(r"data:([\w/+.-]+);base64,(.*)", data, re.S) + if len(points) < 2 or not found: + self.skipped += 1 + return + xs, ys = [x for x, _ in points], [y for _, y in points] + rect = pymupdf.Rect(self.p(min(xs), max(ys)), self.p(max(xs), min(ys))) + blob = base64.b64decode(found.group(2)) + try: + if "svg" in found.group(1): + with pymupdf.open(stream=blob, filetype="svg") as svg: + with pymupdf.open("pdf", svg.convert_to_pdf()) as pdf: + self.page.show_pdf_page(rect, pdf, 0, keep_proportion=True) + else: + self.page.insert_image(rect, stream=blob, keep_proportion=True) + except (RuntimeError, ValueError): + self.skipped += 1 + + def arc(self, center: tuple[float, float], radius: float, shape: dict[str, Any]) -> None: + start, end = float(shape["startAngle"]), float(shape["endAngle"]) + if shape.get("counterClockwise") is False: + start, end = end, start + sweep = (end - start) % (2 * math.pi) or 2 * math.pi + steps = max(8, int(sweep / (math.pi / 36))) + self.line( + [ + ( + center[0] + radius * math.cos(start + sweep * i / steps), + center[1] + radius * math.sin(start + sweep * i / steps), + ) + for i in range(steps + 1) + ] + ) + + def entity(self, entity: dict[str, Any]) -> None: + shape = entity.get("shapeData") or {} + kind = entity.get("type") + if kind == "Image": + self.image(entity) + return + if kind == "Table": + self.stroke(entity) + lines, texts = table_parts(shape) + for x1, y1, x2, y2 in lines: + self.line([(x1, y1), (x2, y2)]) + color = (shape.get("style") or {}).get("textColor") + for one in texts: # ponytail: 칸 글색 · 굵게는 표 글색 하나로 + options = {"fontSize": one["height"], "textAlign": one["align"], "textColor": color} + self.text(one["text"], (one["x"], one["y"]), options) + return + if kind == "Hatch": + ring = [xy for v in shape.get("points") or [] if (xy := _xy(v))] + if len(ring) >= 3: + options = shape.get("options") or {} + # ponytail: 무늬(선 · 격자) 해치는 색 테두리만 — 칸 채움 무늬가 필요하면 clip 경로로 + fill = ( + paint_color(options.get("color")) if options.get("style") == "solid" else None + ) + self.stroke(entity, fill) + self.line([*ring, ring[0]]) + return + start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint")) + if start and end: + self.stroke(entity) + self.line([start, end]) + elif (base := _xy(shape.get("basePoint"))) and isinstance(shape.get("label"), str): + self.text(shape["label"], base, shape.get("options") or {}) + elif point := _xy(shape.get("point")): + self.stroke(entity) + self.circle(point, POINT_RADIUS) + elif (center := _xy(shape.get("center"))) and isinstance(shape.get("radius"), (int, float)): + self.stroke(entity) + angles = (shape.get("startAngle"), shape.get("endAngle")) + if all(isinstance(a, (int, float)) for a in angles): + self.arc(center, float(shape["radius"]), shape) + else: + self.circle(center, float(shape["radius"])) + else: + vertices = [xy for v in shape.get("points") or [] if (xy := _xy(v))] + if len(vertices) >= 2: + self.stroke(entity) + self.line([*vertices, vertices[0]]) + for child in entity.get("children") or []: + self.entity(child) + + +def add_drawing_page(doc: pymupdf.Document, drawing: dict[str, Any]) -> tuple[str, int]: + """도면 한 장을 쪽 하나로 덧붙임 → (종이 이름, 못 넣은 그림 수).""" + entities = visible_entities(drawing) + world, (width_mm, height_mm) = page_frame(entities) + page = doc.new_page(width=width_mm * PT_PER_MM, height=height_mm * PT_PER_MM) + painter = _Painter(page, world, _font()) + for entity in entities: + painter.entity(entity) + painter.finish() + return paper_name(width_mm, height_mm), painter.skipped diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py new file mode 100644 index 000000000..e87803cb7 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py @@ -0,0 +1,99 @@ +"""인쇄 미리보기 도면(PLAN 302-2) — 목록 · PDF 길. + + GET /api/projects/{id}/print/drawings + → [{id, source, kind, label, number, confirmed, paper, images_skipped}] + POST /api/projects/{id}/print/drawings-pdf {ids?} → application/pdf + 목록 차례 · 장마다 한 쪽 · 머리 X-Aislo-Pages · + X-Aislo-Skipped-Images(못 넣은 그림 {id: 수} JSON) + +로그인 · 회사 · 프로젝트 권한은 이 라우터가 스스로 검(B09 라우터가 include 하든 앱이 바로 붙이든). +저장 안 함 — 매번 만듦 · B07 정본 · 확정 상태 그대로. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from uuid import UUID + +import pymupdf +from fastapi import APIRouter, Depends, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from B09_Estimation.draft import B09_Draft_Print_Drawings_Source as source +from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import add_drawing_page +from common_util.common_util_auth import require_company, require_project_access, verify_session + +logger = logging.getLogger(__name__) +router = APIRouter( + prefix="/api/projects", + tags=["B09 Print Drawings (draft)"], + dependencies=[ + Depends(verify_session), + Depends(require_company), + Depends(require_project_access), + ], +) + + +class DrawingsPdfRequest(BaseModel): + ids: list[str] | None = None + + +def _error(status: int, message: str) -> JSONResponse: + return JSONResponse(status_code=status, content={"status": "error", "message": message}) + + +@router.get("/{project_id}/print/drawings") +async def get_print_drawings(project_id: UUID) -> JSONResponse: + try: + items, _ = await source.drawing_items(project_id) + except Exception: + logger.exception("인쇄 도면 목록 실패: %s", project_id) + return _error(500, "도면 목록을 읽지 못했습니다.") + return JSONResponse(items) + + +def _pdf(drawings: list[dict], ids: list[str]) -> tuple[bytes, dict[str, int]]: + doc = pymupdf.open() + skipped: dict[str, int] = {} + for drawing_id, drawing in zip(ids, drawings, strict=True): + _, missing = add_drawing_page(doc, drawing) + if missing: + skipped[drawing_id] = missing + doc.subset_fonts() # 한글 글꼴 = 쓴 글자만 + return doc.tobytes(garbage=3, deflate=True), skipped + + +@router.post("/{project_id}/print/drawings-pdf") +async def post_print_drawings_pdf( + project_id: UUID, request: DrawingsPdfRequest | None = None +) -> Response: + try: + items, context = await source.drawing_items(project_id) + by_id = {item["id"]: item for item in items} + ids = request.ids if request and request.ids is not None else list(by_id) + unknown = [one for one in ids if one not in by_id] + if unknown: + return _error(400, f"목록에 없는 도면입니다: {', '.join(unknown)}") + if not ids: + return _error(404, "인쇄할 도면이 없습니다.") + chosen = [by_id[one] for one in ids] + drawings = await source.drawings_in_order(project_id, chosen, context) + body, skipped = await asyncio.to_thread(_pdf, drawings, ids) + except ValueError as exc: + return _error(400, str(exc)) + except Exception: + logger.exception("인쇄 도면 PDF 실패: %s", project_id) + return _error(500, "도면 PDF 를 만들지 못했습니다.") + return Response( + body, + media_type="application/pdf", + headers={ + "Content-Disposition": 'inline; filename="drawings.pdf"', + "X-Aislo-Pages": str(len(ids)), + "X-Aislo-Skipped-Images": json.dumps(skipped), + }, + ) diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py new file mode 100644 index 000000000..20beef2f6 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py @@ -0,0 +1,240 @@ +"""인쇄 미리보기 도면(PLAN 302-2) — 목록 · 도면 모으기. + +차례 = B07 도면번호(B07 목록 그대로) → +구조물 조합 도면(도번 차례 · 도번마다 한 번 · `도면` 다음 `더한장`). +B07 도면 = B07 단건 조회 그대로(확정본 = 저장 JSON · 미확정 = 서버 조립) + 미확정 횡단 장 구조물 = +B07 화면과 같은 TS 를 Node 로(`B09_Draft_Print_Drawings_Node.ts` · CLAUDE.md 5장 ②). +구조물 도면 = 프로젝트 사본 `도면.json` + 얹는 도각(M02 `structure_frame` · 장 도번). +저장 · 확정 상태는 손대지 않음 — 매번 읽어 만듦. +""" + +from __future__ import annotations + +import asyncio +import logging +import subprocess +from pathlib import Path +from typing import Any +from uuid import UUID + +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + entities_bbox, + template_entities, + use_project_templates, + use_title_fields, +) +from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import paper_name +from common_util.common_util_node_bundle import ROOT, bundle_stale, node_env, run_bundle_json + +logger = logging.getLogger(__name__) + +B07_PREFIX = "b07:" +STRUCTURE_PREFIX = "structure:" +_ENTRY = Path(__file__).with_name("B09_Draft_Print_Drawings_Node.ts") +# 번들은 git 밖(tmp) — package.json 을 안 건드리려고 vite 를 바로 부름 +BUNDLE = ROOT / "tmp" / "print_drawings_node" / "B09_Draft_Print_Drawings_Node.js" +_BUILD = [ + "node", + "./config/node_modules/vite/bin/vite.js", + "build", + "--configLoader", + "runner", + "--ssr", + "../B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts", + "--outDir", + "../tmp/print_drawings_node", +] + + +async def project_root(project_id: UUID) -> Path: + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from common_util.common_util_storage import resolve_stored_project_path + from config.config_db import get_db_pool + + async with get_db_pool().acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + return Path(resolve_stored_project_path(stored)).resolve() + + +async def title_fields(project_id: UUID) -> dict[str, str]: + from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields + + return await title_block_fields(project_id) + + +async def b07_list(project_id: UUID) -> tuple[int | None, list[dict[str, Any]]]: + """(노선 id, B07 목록) — 노선 미확정 · 자료 없음이면 (None, []).""" + from B07_DesignDetail.B07_DesignDetail_Router import get_design_drawing_list + + response = await get_design_drawing_list(project_id) + if not hasattr(response, "drawings"): # JSONResponse = 404 · 409 · 500 + return None, [] + return response.route_id, [item.model_dump() for item in response.drawings] + + +async def b07_drawing(project_id: UUID, drawing_id: str) -> dict[str, Any]: + from B07_DesignDetail.B07_DesignDetail_Router import get_design_drawing + + response = await get_design_drawing(project_id, drawing_id) + if not hasattr(response, "drawing"): + raise ValueError(f"B07 도면 「{drawing_id}」 을 읽지 못했습니다.") + return response.model_dump() + + +async def section_detail(project_id: UUID, route_id: int) -> dict[str, Any] | None: + from B06_Section.B06_Section_Router import get_section_detail + + response = await get_section_detail(project_id, route_id) + dump = getattr(response, "model_dump", None) + return dump(mode="json") if dump else None + + +def _bundle_ready() -> bool: + stale = bundle_stale(BUNDLE) or _ENTRY.stat().st_mtime > BUNDLE.stat().st_mtime + if not stale: + return True + result = subprocess.run( # noqa: S603 — 고정 명령 + _BUILD, cwd=str(ROOT), env=node_env(), capture_output=True, text=True, timeout=300 + ) + if result.returncode != 0: + logger.error("인쇄 도면 Node 번들 빌드 실패:\n%s", result.stderr) + return result.returncode == 0 + + +def place_structures(detail: dict[str, Any], drawings: dict[str, dict[str, Any]]) -> bool: + """미확정 횡단 장에 구조물을 얹음(제자리) — 실패면 거짓(서버 그림만 나감).""" + if not drawings or not _bundle_ready(): + return False + output = run_bundle_json(BUNDLE, "", {"detail": detail, "drawings": drawings}) + placed = (output or {}).get("drawings") if isinstance(output, dict) else None + if not isinstance(placed, dict): + return False + for key, drawing in placed.items(): + if key in drawings and isinstance(drawing, dict): + drawings[key].clear() + drawings[key].update(drawing) + return True + + +def sheet_number(number: str, index: int) -> str: + """장 도번 — 1장 = 도번 · n 째 장 = 도번-n(M02 `sheetNumber` 와 같음).""" + return "" if not number else number if index == 0 else f"{number}-{index + 1}" + + +def structure_sheets(root: Path) -> list[dict[str, Any]]: + """구조물 조합 도면 장 — 도번 차례 · 같은 도번은 한 번 · {id, label, number, drawing}.""" + from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots, structures_dir + from M02_MasterTemplete.M02_Item_Source import read_json + + docs: dict[str, tuple[str, dict[str, Any]]] = {} + for owner, copy in linked_snapshots(root).items(): + if "도면" not in copy["부분"]: + continue + doc = read_json(structures_dir(root) / copy["폴더"] / "도면.json") + if not isinstance(doc, dict) or not isinstance(doc.get("도면"), dict): + continue + number = str(doc.get("도번") or "") + name = str((copy.get("항목") or {}).get("name") or copy["폴더"]) + docs.setdefault(number or f"~{owner}", (name, doc)) + sheets = [] + for key in sorted(docs): + name, doc = docs[key] + number = "" if key.startswith("~") else key + pages = [{"이름": "", "도면": doc["도면"]}, *(doc.get("더한장") or [])] + for index, page in enumerate(pages): + if not isinstance(page, dict) or not isinstance(page.get("도면"), dict): + continue + label = f"{name} · {page['이름']}" if page.get("이름") else name + numbered = sheet_number(number, index) or f"{key.lstrip('~')}-{index + 1}" + sheets.append( + { + "id": f"{STRUCTURE_PREFIX}{numbered}", + "label": label, + "number": sheet_number(number, index), + "drawing": page["도면"], + } + ) + return sheets + + +def with_frame(drawing: dict[str, Any], number: str) -> dict[str, Any]: + """구조물 도면 + 얹는 도각(M02 화면과 같은 `structure_frame` · 표제란 = 프로젝트 값).""" + from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import structure_frame + + frame = structure_frame(number) + layers = [*(drawing.get("layers") or []), *([frame["layer"]] if frame["layer"] else [])] + return { + **drawing, + "entities": [*frame["entities"], *drawing.get("entities", [])], + "layers": layers, + } + + +async def drawing_items(project_id: UUID) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """(목록, 만들 때 쓸 문맥). + + 목록 칸 = {id, source, kind, label, number, confirmed, paper, images_skipped}. + """ + root = await project_root(project_id) + use_project_templates(root) + template = entities_bbox(template_entities()) + paper = paper_name(template[2] - template[0], template[3] - template[1]) if template else None + route_id, b07 = await b07_list(project_id) + items = [ + { + "id": f"{B07_PREFIX}{item['id']}", + "source": "b07", + "kind": item["kind"], + "label": item["label"], + "number": str(number), + "confirmed": bool(item.get("confirmed")), + "paper": paper, + "images_skipped": None, # PDF 만들 때 셈 — 응답 머리 X-Aislo-Skipped-Images + } + for number, item in enumerate(b07, start=1) + ] + sheets = await asyncio.to_thread(structure_sheets, root) + items += [ + { + "id": sheet["id"], + "source": "structure", + "kind": "structure", + "label": sheet["label"], + "number": sheet["number"], + "confirmed": True, + "paper": paper, + "images_skipped": None, + } + for sheet in sheets + ] + return items, {"root": root, "route_id": route_id, "sheets": {s["id"]: s for s in sheets}} + + +async def drawings_in_order( + project_id: UUID, items: list[dict[str, Any]], context: dict[str, Any] +) -> list[dict[str, Any]]: + """고른 목록 차례대로 CAD 도면 — 미확정 횡단 장은 구조물을 얹어서.""" + base_fields = await title_fields(project_id) + drawings: list[dict[str, Any]] = [] + unconfirmed_cross: dict[str, dict[str, Any]] = {} + for item in items: + if item["source"] == "b07": + response = await b07_drawing(project_id, item["id"][len(B07_PREFIX) :]) + drawing = response["drawing"] + if item["kind"] == "cross" and not response.get("confirmed"): + unconfirmed_cross[item["id"]] = drawing + else: + sheet = context["sheets"][item["id"]] + use_project_templates(context["root"]) + use_title_fields(base_fields) + drawing = with_frame(sheet["drawing"], sheet["number"]) + drawings.append(drawing) + if unconfirmed_cross and context.get("route_id") is not None: + detail = await section_detail(project_id, int(context["route_id"])) + if detail is None or not await asyncio.to_thread( + place_structures, detail, unconfirmed_cross + ): + logger.warning( + "인쇄 도면 — 미확정 횡단 장 구조물을 못 얹음(서버 그림만): %s", project_id + ) + return drawings diff --git a/requirements.txt b/requirements.txt index 903388fd7..09d89ee34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ et_xmlfile==2.0.0 fastapi==0.104.1 bcrypt>=4.1,<5 email-validator>=2.1,<3 +ezdxf==1.4.4 fiona==1.10.1 geopandas==1.1.4 h11==0.16.0 @@ -32,6 +33,7 @@ playwright==1.62.0 psutil>=6.1,<7 pydantic==2.5.0 pydantic_core==2.14.1 +PyMuPDF==1.28.2 PyMySQL==1.2.0 pyparsing==3.3.2 pyproj==3.7.2 diff --git a/resources/tester/test_302_2_print_drawings.py b/resources/tester/test_302_2_print_drawings.py new file mode 100644 index 000000000..27e722b59 --- /dev/null +++ b/resources/tester/test_302_2_print_drawings.py @@ -0,0 +1,273 @@ +"""PLAN 302-2 인쇄 미리보기 도면 PDF — 목록 차례 · 쪽 수 · 쪽 크기 · 한글 · 숨긴 층 · 권한. + +임시 폴더 견본만 씀(공용 storage 안 씀) — B07 목록 · 단건 · 표제란 값은 바꿔 끼움. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from uuid import uuid4 + +import pymupdf +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + frame_entities, + usable_bbox, + use_project_templates, + use_title_fields, +) +from B09_Estimation.draft import B09_Draft_Print_Drawings_Router as router_module +from B09_Estimation.draft import B09_Draft_Print_Drawings_Source as source +from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import ( + PT_PER_MM, + add_drawing_page, + paint_color, +) +from common_util import common_util_auth as auth + +TITLE = {"공사명": "가나다임도개설공사", "용역회사": "라마바설계"} + + +def _text(label: str, x: float, y: float, layer: str) -> dict: + return { + "id": str(uuid4()), + "type": "Text", + "lineColor": "#ffffff", + "lineWidth": 1, + "layerId": layer, + "shapeData": { + "basePoint": {"x": x, "y": y}, + "label": label, + "options": {"fontSize": 10, "textAlign": "left", "textColor": "#ffffff"}, + }, + } + + +def _b07_drawing(drawing_id: str) -> dict: + """도각(시스템 A1 · 표제란 = TITLE) + 보이는 글 + 숨긴 층 · 동결 층 글.""" + use_project_templates(None) + use_title_fields(TITLE) + entities = frame_entities(drawing_id, usable_bbox(), fit=False, fields={"도면명": "종단면도"}) + entities += [ + _text("보이는글", 200, 300, "draw"), + _text("숨은글", 200, 320, "hidden"), + _text("얼린글", 200, 340, "frozen"), + ] + layers = [ + {"id": "draw", "name": "그림", "isVisible": True, "isLocked": False}, + {"id": "hidden", "name": "숨김", "isVisible": False, "isLocked": False}, + {"id": "frozen", "name": "동결", "isVisible": True, "isLocked": False, "isFrozen": True}, + ] + return {"format": "openwebcad", "entities": entities, "layers": layers} + + +def _structure_doc(number: str, more: list[str] = ()) -> dict: + sheet = { + "format": "openwebcad", + "entities": [_text("구조물그림", 300, 300, "기본")], + "layers": [], + } + return { + "도번": number, + "도면": sheet, + "더한장": [{"장id": f"s{i}", "이름": name, "도면": sheet} for i, name in enumerate(more)], + } + + +@pytest.fixture +def sample(tmp_path, monkeypatch): + """B07 둘(종단 · 표지) + 구조물 사본 셋(SD-2 둘 = 한 번 · 더한장 하나 · SD-1 하나).""" + from M02_MasterTemplete import M02_Item_Snapshot as snapshot + + base = tmp_path / "structures" + copies = { + "pipe@10__a": ("A", _structure_doc("SD-000002", ["상세"])), + "pipe@20__b": ("B", _structure_doc("SD-000002", ["상세"])), + "pipe@30__c": ("C", _structure_doc("SD-000001")), + } + linked = {} + for owner, (folder, doc) in copies.items(): + (base / folder).mkdir(parents=True) + (base / folder / "도면.json").write_text(json.dumps(doc, ensure_ascii=False), "utf-8") + linked[owner] = {"항목": {"name": f"구조물{folder}"}, "폴더": folder, "부분": ["도면"]} + monkeypatch.setattr(snapshot, "linked_snapshots", lambda root: linked) + monkeypatch.setattr(snapshot, "structures_dir", lambda root: base) + + async def root(_pid): + return tmp_path + + async def fields(_pid): + return dict(TITLE) + + async def b07_list(_pid): + return 7, [ + { + "id": "longitudinal_1", + "kind": "longitudinal", + "label": "종단도(1)", + "confirmed": True, + }, + {"id": "cover", "kind": "cover", "label": "표지", "confirmed": False}, + ] + + async def b07_drawing(_pid, drawing_id): + return {"drawing": _b07_drawing(drawing_id), "confirmed": True} + + monkeypatch.setattr(source, "project_root", root) + monkeypatch.setattr(source, "title_fields", fields) + monkeypatch.setattr(source, "b07_list", b07_list) + monkeypatch.setattr(source, "b07_drawing", b07_drawing) + return tmp_path + + +def _client(session: dict | None = None, project_company: int | None = None) -> TestClient: + app = FastAPI() + app.include_router(router_module.router) + if session is not None: + app.dependency_overrides[auth.verify_session] = lambda: session + if project_company is None: + app.dependency_overrides[auth.require_project_access] = lambda: session + return TestClient(app) + + +SESSION = {"user_id": 1, "company_id": 1, "role": "USER", "is_master": False} + + +def test_paint_color_theme_짝(): + """openwebcad theme.ts paintColor(밝은 바탕) 손셈. + + 자동색 검정 · 밝은 노랑 휘도 0.3 으로 · 어두운 색 그대로. + """ + assert paint_color("#ffffff") == paint_color("#FFF") == paint_color("#f5f7fa") == (0, 0, 0) + assert paint_color("#f6d55c") == (89 / 255, 77 / 255, 33 / 255) + assert paint_color("#336699") == (0x33 / 255, 0x66 / 255, 0x99 / 255) + + +def test_목록_차례_B07_번호_다음_도번_한_번(sample): + pid = uuid4() + body = _client(SESSION).get(f"/api/projects/{pid}/print/drawings").json() + assert [item["id"] for item in body] == [ + "b07:longitudinal_1", + "b07:cover", + "structure:SD-000001", + "structure:SD-000002", + "structure:SD-000002-2", + ] + assert [item["number"] for item in body][:2] == ["1", "2"] + assert body[3]["label"] == "구조물A" and body[4]["label"] == "구조물A · 상세" + assert {item["paper"] for item in body} == {"A1"} + assert {item["source"] for item in body} == {"b07", "structure"} + + +def test_PDF_쪽수_크기_한글_숨긴층(sample): + pid = uuid4() + response = _client(SESSION).post(f"/api/projects/{pid}/print/drawings-pdf", json={}) + assert response.status_code == 200, response.text + assert response.headers["content-type"] == "application/pdf" + assert response.headers["x-aislo-pages"] == "5" + assert json.loads(response.headers["x-aislo-skipped-images"]) == {} + doc = pymupdf.open(stream=response.content, filetype="pdf") + assert doc.page_count == 5 + for page in doc: + assert abs(page.rect.width / PT_PER_MM - 840) < 0.5 + assert abs(page.rect.height / PT_PER_MM - 594) < 0.5 + first = doc[0].get_text() + assert "가나다임도개설공사" in first and "라마바설계" in first # 표제란 한글 그대로 + assert "보이는글" in first + assert "숨은글" not in first and "얼린글" not in first + # 구조물 장 = 얹은 도각 + 장 도번 · 표제란 = 프로젝트 값 + assert "구조물그림" in doc[2].get_text() and "SD-000001" in doc[2].get_text() + assert "SD-000002-2" in doc[4].get_text() and "가나다임도개설공사" in doc[4].get_text() + + +def test_PDF_고른_차례_모르는_id(sample): + pid = uuid4() + client = _client(SESSION) + picked = ["structure:SD-000001", "b07:cover"] + response = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": picked}) + doc = pymupdf.open(stream=response.content, filetype="pdf") + assert doc.page_count == 2 and "구조물그림" in doc[0].get_text() + bad = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": ["b07:없음"]}) + assert bad.status_code == 400 + + +class _FakePool: + """require_project_access 가 읽는 프로젝트 회사 한 줄.""" + + def __init__(self, company_id: int): + self.row = (company_id,) + + def acquire(self): + pool = self + + class _Cursor: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, *_): + return None + + async def fetchone(self): + return pool.row + + class _Connection: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + def cursor(self): + return _Cursor() + + return _Connection() + + +def test_로그인_회사_권한(sample, monkeypatch): + pid = uuid4() + path = f"/api/projects/{pid}/print/drawings" + assert _client().get(path).status_code == 401 # 쿠키 없음 + no_company = {**SESSION, "company_id": None} + assert _client(no_company, project_company=0).get(path).status_code == 403 + monkeypatch.setattr(auth, "get_db_pool", lambda: _FakePool(2)) + other = _client(SESSION, project_company=2) + assert other.get(path).status_code == 403 # 남의 회사 프로젝트 + assert other.post(f"/api/projects/{pid}/print/drawings-pdf", json={}).status_code == 403 + monkeypatch.setattr(auth, "get_db_pool", lambda: _FakePool(1)) + assert _client(SESSION, project_company=1).get(path).status_code == 200 + + +def test_그림_넣기_못_넣은_수(): + png = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 4, 4), 0).tobytes("png") + import base64 + + def image(data: str) -> dict: + points = [{"x": 10, "y": 10}, {"x": 50, "y": 10}, {"x": 50, "y": 50}, {"x": 10, "y": 50}] + return {"type": "Image", "layerId": "a", "shapeData": {"points": points, "imageData": data}} + + drawing = { + "entities": [ + image("data:image/png;base64," + base64.b64encode(png).decode()), + image("/api/assets/없는그림.png"), + ], + "layers": [], + } + doc = pymupdf.open() + paper, skipped = add_drawing_page(doc, drawing) + assert skipped == 1 and len(doc[0].get_images()) == 1 + + +def test_미확정_횡단_구조물_Node_번들이_돎(): + """서버 Node(화면 TS 그대로) — 번들 빌드 · 실행 · 배치 없는 장은 그대로.""" + entity = _text("서버그림", 0, 0, "draw") + drawings = {"b07:cross_x": {"entities": [entity], "cross_placements": []}} + assert source.place_structures({"cross_sections": []}, drawings) is True + assert drawings["b07:cross_x"]["entities"] == [entity] + assert Path(source.BUNDLE).is_file()