From 8b284459c6e6dcf3ccdd919e432f1ba39b1ac68a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 30 Aug 2026 16:49:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=9A=A1=EB=8B=A8=EB=8F=84?= =?UTF-8?q?=EC=97=90=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=EC=9D=84=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B3=A0,=20=EC=9E=A5=EC=9D=84=20=EB=B9=88=ED=8B=88?= =?UTF-8?q?=EC=97=86=EC=9D=B4=20=EC=B1=84=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조물(배수관·기슭막이·세월교·BOX암거·물넘이포장)이 횡단도에서 빠져 있었다. 기하 산식이 전부 B06 화면(TS)에 있어 서버가 도형을 만들 수 없었기 때문이다. 파이썬으로 옮겨 적는 대신, 화면에 붙이지 않은 SVG에 B06 그리기 함수를 그대로 불러 그린 뒤 그 도형을 CAD 엔티티로 옮긴다. 산식은 한 벌로 남고, 앞으로 B06에 붙는 구조물·치수·글자는 손대지 않아도 도면에 따라온다. 자리를 맞추려면 실좌표(m)를 종이(mm)로 옮긴 값이 필요하다 — 서버가 측점별 cross_placements로 실어 보낸다. 장 배치는 전체 블록의 최대치로 칸을 통일하던 것을 버리고, 블록 크기를 먼저 재서 가장 많이 담기는 행 수를 고른다. 열폭은 그 열의 최대폭, 행높이는 그 행의 최대높이라 행·열은 그대로 맞는다. 같은 노선에서 4장이 3장이 됐다(12/6/5). Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Api_Fetch.ts | 10 + .../B07_DesignDetail_Engine_Cad.py | 13 + .../B07_DesignDetail_Engine_Cad_Sheet.py | 97 ++++-- .../B07_DesignDetail_UI_Cad_Structures.ts | 305 ++++++++++++++++++ B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 6 + 5 files changed, 401 insertions(+), 30 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 75fa4ff2..69640b94 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -12,6 +12,16 @@ export interface DesignDrawingItem { export interface CadDrawing { entities: Record[]; + /** 횡단도 측점별 실좌표(m) → 종이(mm) 변환값. 구조물을 같은 자리에 얹는 데 쓴다. */ + cross_placements?: { + chainage_m: number; + ox: number; + oy: number; + dy: number; + mm_per_m: number; + x0: number; + x1: number; + }[]; layers: { id: string; name: string; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index e43b549f..a28569b5 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -512,6 +512,19 @@ def build_cross_drawing( return { "format": DRAWING_FORMAT, "entities": entities, + # 실좌표(m) -> 종이(mm) 변환값. 프론트가 B06 산식으로 만든 구조물을 같은 자리에 + # 얹는 데 쓴다 — x_mm = offset*mm_per_m + ox, y_mm = (elev - dy)*mm_per_m + oy. + "cross_placements": [ + { + "chainage_m": float(source.get("chainage_m", 0.0)), + "ox": ox, + "oy": oy, + "dy": dy, + "mm_per_m": CROSS_MM, + "x0": x0, + "x1": x1, + } + ], "layers": [ _layer(GROUND_LAYER_ID, "Existing Ground", locked=True), _layer(DESIGN_LAYER_ID, "Design Plan"), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 2efff8cb..6eca6c62 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -81,55 +81,90 @@ def section_block_size( return (width, top + below) +def _pack( + blocks: list[tuple[int, float, float]], start: int, rows: int +) -> tuple[int, list[float], list[float]]: + """blocks[start:]를 rows행 **열 우선**으로 담아 (담은 개수, 열폭, 행높이)를 낸다. + + 열폭은 그 열에 든 블록의 최대폭, 행높이는 그 행에 든 블록의 최대높이다 — 칸을 + 전체 최대치로 통일하지 않으면서 행·열은 맞춘다(2026-08-30 사용자 확정). + """ + usable_w, usable_h = usable_area() + col_widths: list[float] = [] + row_heights: list[float] = [0.0] * rows + count = 0 + for index, (_chainage, width, height) in enumerate(blocks[start:]): + column, row = divmod(index, rows) + current = col_widths[column] if column < len(col_widths) else 0.0 + new_col = max(current, width + _BLOCK_GAP_MM) + new_row = max(row_heights[row], height + _BLOCK_GAP_MM) + if sum(col_widths[:column]) + new_col > usable_w: + break + if sum(row_heights) - row_heights[row] + new_row > usable_h: + break + if column < len(col_widths): + col_widths[column] = new_col + else: + col_widths.append(new_col) + row_heights[row] = new_row + count = index + 1 + return count, col_widths, row_heights + + +def _slots(col_widths: list[float], row_heights: list[float], count: int) -> list[list[float]]: + """칸 중심 좌표 — 좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열로 넘어간다.""" + usable_w, usable_h = usable_area() + rows = len(row_heights) + slots: list[list[float]] = [] + for index in range(count): + column, row = divmod(index, rows) + x = -usable_w / 2.0 + sum(col_widths[:column]) + col_widths[column] / 2.0 + y = -usable_h / 2.0 + sum(row_heights[:row]) + row_heights[row] / 2.0 + slots.append([x, y]) + return slots + + def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]: """(측점, 폭, 높이) 목록을 A1 장으로 나눈다. - 칸 크기는 전체 블록의 최대 폭·높이로 통일한다 — 장마다 칸이 달라지면 - 도면끼리 비교가 안 되기 때문이다. 한 장에 cols x rows개가 들어간다. + 블록 크기를 먼저 재서 **가장 많이 담기는 행 수**를 고르고, 그 행·열 격자에 + 담는다(2026-08-30 사용자 지시 — 전체 최대치 통일은 여백이 너무 많았다). """ - if not blocks: - return [] - usable_w, usable_h = usable_area() - cell_w = max(w for _c, w, _h in blocks) + _BLOCK_GAP_MM - cell_h = max(h for _c, _w, h in blocks) + _BLOCK_GAP_MM - cols = max(1, int(usable_w // cell_w)) - rows = max(1, int(usable_h // cell_h)) - per_sheet = cols * rows - sheets: list[dict[str, Any]] = [] - for index in range(0, len(blocks), per_sheet): - group = blocks[index : index + per_sheet] - number = index // per_sheet + 1 + start = 0 + while start < len(blocks): + best: tuple[int, list[float], list[float]] = (0, [], []) + for rows in range(1, len(blocks) - start + 1): + packed = _pack(blocks, start, rows) + if packed[0] > best[0]: + best = packed + count, col_widths, row_heights = best + if count == 0: # 한 칸도 못 담을 만큼 큰 블록 — 그래도 한 장에 하나는 놓는다. + count, col_widths, row_heights = 1, [blocks[start][1]], [blocks[start][2]] + group = blocks[start : start + count] + number = len(sheets) + 1 sheets.append( { "id": f"cross_s{number:02d}", "number": number, "chainages": [chainage for chainage, _w, _h in group], - "cols": cols, - "rows": rows, - "cell": (cell_w, cell_h), + "rows": len(row_heights), + "slots": _slots(col_widths, row_heights, count), } ) + start += count return sheets -def _cell_center(index: int, rows: int, cell: tuple[float, float]) -> tuple[float, float]: - """좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열로 넘어간다.""" - cell_w, cell_h = cell - usable_w, usable_h = usable_area() - column, row = divmod(index, rows) - x = -usable_w / 2.0 + cell_w * (column + 0.5) - y = -usable_h / 2.0 + cell_h * (row + 0.5) - return (x, y) - - def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> dict[str, Any]: """한 장을 만든다. sections는 이 장에 담을 측점 입력들(배치 순서대로).""" entities: list[dict[str, Any]] = [] - rows = int(sheet.get("rows", 1)) - cell = tuple(sheet.get("cell", (100.0, 100.0))) # type: ignore[arg-type] + placements: list[dict[str, Any]] = [] + slots = sheet.get("slots") or [] for index, section in enumerate(sections): + if index >= len(slots): + break seed_id = f"{sheet['id']}:{section['chainage']}" # 1) 원점(0,0)에 한 번 만들어 블록이 원점 대비 어디에 놓이는지 잰다. probe = build_cross_drawing( @@ -144,7 +179,7 @@ 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, center_y = _cell_center(index, rows, cell) + center_x, center_y = slots[index] # 2) 칸 가운데에 오도록 원점을 옮겨 다시 만든다. origin = ( center_x - (min_x + max_x) / 2.0, @@ -160,6 +195,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> origin=origin, ) entities.extend(placed["entities"]) + placements.extend(placed.get("cross_placements") or []) bbox = entities_bbox(entities) if bbox: @@ -168,6 +204,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> return { "format": DRAWING_FORMAT, "entities": entities, + "cross_placements": placements, "layers": [ _layer(GROUND_LAYER_ID, "Existing Ground", locked=True), _layer(DESIGN_LAYER_ID, "Design Plan"), diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts new file mode 100644 index 00000000..48f0f421 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -0,0 +1,305 @@ +/* ============================================================================= + * B07_DesignDetail_UI_Cad_Structures.ts + * 횡단도 **구조물 작도** — 배수관·기슭막이·세월교·BOX암거·물넘이포장. + * + * 산식은 새로 만들지 않는다. B06 화면이 쓰는 기하·그리기 함수를 **그대로** 불러 + * 화면에 붙이지 않은 오프스크린 SVG에 그린 뒤, 그 도형을 CAD 엔티티로 옮긴다 + * (2026-08-30 사용자 확정: 프론트에서 B06 산식 재사용 — 파이썬 포팅 금지). + * 앞으로 B06에 구조물·치수·글자가 붙으면 여기 손대지 않아도 도면에 따라온다. + * + * 자리 맞추기는 서버가 도면에 실어 보내는 `cross_placements`가 정한다: + * x_mm = offset*mm_per_m + ox, y_mm = (elev - dy)*mm_per_m + oy + * SVG는 y가 아래로 자라므로 그릴 때 부호를 뒤집고 수확할 때 되돌린다. + * ========================================================================== */ + +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { fetchSectionDetail } from "../B06_Section/B06_Section_Api_Fetch"; +import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box"; +import { + computeBoxLayout, + DEFAULT_BOX_SIDE_ADJUST, +} from "../B06_Section/B06_Section_UI_Cross_Box_Geom"; +import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert"; +import { + DEFAULT_BASIN_ADJUST, + ZERO_ADJUST, +} from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; +import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; +import { + computeCardCulvert, + culvertLinkFor, +} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; +import type { + ExtraWallControl, + InletStructureControl, + RevetOffsetControl, +} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; +import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford"; +import { + computeFordLayout, + DEFAULT_FORD_WALL_ADJUST, +} from "../B06_Section/B06_Section_UI_Cross_Ford_Geom"; +import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; +import { + appendRevetmentOverlay, + computeRevetmentLayout, +} from "../B06_Section/B06_Section_UI_Cross_Revetment"; + +/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */ +export interface CrossPlacement { + chainage_m: number; + ox: number; + oy: number; + dy: number; + mm_per_m: number; + x0: number; + x1: number; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */ +const STRUCTURE_LAYER_ID = "b08-structure"; +const STRUCTURE_COLOR = "#f6d55c"; +/** 글자 크기(종이 mm) — SVG는 CSS로 크기를 잡아 오프스크린에서는 읽을 수 없다. */ +const LABEL_FONT_MM = 2.0; +/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ +const CHAINAGE_TOLERANCE_M = 0.02; + +/** 정의부(해칭 패턴·클립)는 도형이 아니다. 클립된 해칭은 1차 제외(잘라 낼 수단이 없다). */ +const SKIP_SELECTOR = "defs, clipPath, pattern, g[clip-path]"; + +type Entity = Record; +type XY = [number, number]; + +function baseEntity(type: string, shapeData: unknown): Entity { + return { + id: crypto.randomUUID(), + type, + lineColor: STRUCTURE_COLOR, + lineWidth: 1, + layerId: STRUCTURE_LAYER_ID, + shapeData, + }; +} + +function lineEntity(start: XY, end: XY): Entity { + return baseEntity("Line", { + startPoint: { x: start[0], y: start[1] }, + endPoint: { x: end[0], y: end[1] }, + }); +} + +/** 점열 → PolyLine(자식 Line 묶음). 서버 도면의 폴리라인과 같은 직렬화다. */ +function polyEntity(points: XY[]): Entity | null { + if (points.length < 2) return null; + const children: Entity[] = []; + for (let index = 0; index < points.length - 1; index += 1) { + children.push(lineEntity(points[index], points[index + 1])); + } + const poly = baseEntity("PolyLine", null); + poly.children = children; + return poly; +} + +function textEntity(label: string, at: XY, align: string): Entity { + return baseEntity("Text", { + label, + basePoint: { x: at[0], y: at[1] }, + options: { + textDirection: { x: 1, y: 0 }, + textAlign: align, + textColor: STRUCTURE_COLOR, + fontSize: LABEL_FONT_MM, + fontFamily: "sans-serif", + }, + }); +} + +function attr(element: SVGElement, name: string): number { + return Number(element.getAttribute(name) ?? 0); +} + +/** SVG 좌표(y 아래로 증가) → 도면 좌표(y 위로 증가). */ +function flip(x: number, y: number): XY { + return [x, -y]; +} + +function parsePoints(element: SVGElement): XY[] { + const raw = (element.getAttribute("points") ?? "").trim(); + if (!raw) return []; + const numbers = raw.split(/[\s,]+/).map(Number); + const points: XY[] = []; + for (let index = 0; index + 1 < numbers.length; index += 2) { + points.push(flip(numbers[index], numbers[index + 1])); + } + return points; +} + +/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다. */ +function harvest(root: SVGElement): Entity[] { + const entities: Entity[] = []; + const nodes = root.querySelectorAll("polygon, polyline, line, circle, text"); + for (const element of Array.from(nodes)) { + if (element.closest(SKIP_SELECTOR)) continue; + const tag = element.tagName.toLowerCase(); + if (tag === "polygon" || tag === "polyline") { + const points = parsePoints(element); + if (tag === "polygon" && points.length > 2) points.push(points[0]); + const poly = polyEntity(points); + if (poly) entities.push(poly); + } else if (tag === "line") { + entities.push( + lineEntity( + flip(attr(element, "x1"), attr(element, "y1")), + flip(attr(element, "x2"), attr(element, "y2")), + ), + ); + } else if (tag === "circle") { + const [cx, cy] = flip(attr(element, "cx"), attr(element, "cy")); + entities.push(baseEntity("Circle", { center: { x: cx, y: cy }, radius: attr(element, "r") })); + } else if (tag === "text") { + const label = (element.textContent ?? "").trim(); + if (!label) continue; + const anchor = element.getAttribute("text-anchor"); + const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center"; + entities.push(textEntity(label, flip(attr(element, "x"), attr(element, "y")), align)); + } + } + return entities; +} + +// --------------------------------------------------------------------------- +// 정본(design)만 읽는 조작값 — B07은 편집하지 않으므로 되받기·토스트는 빈 동작이다. +// --------------------------------------------------------------------------- +function storedWallAdjust(section: CrossSection, role: string): WallAdjust { + const stored = section.design?.revet_adjust?.[role]; + return stored ? { ...ZERO_ADJUST, ...(stored as Partial) } : { ...ZERO_ADJUST }; +} + +const revetOffset: RevetOffsetControl = { + adjustFor: (section, role) => storedWallAdjust(section, role), + storedAdjustFor: (section, role) => + section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null, + selectedFor: () => null, + highlightFor: () => null, + select: () => undefined, + syncApplied: () => undefined, + update: () => undefined, + reset: () => undefined, +}; + +const extraWalls: ExtraWallControl = { + countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0, + setCount: () => undefined, + equalize: () => undefined, + consumeEqualize: () => false, + syncCount: () => undefined, +}; + +const inletStructure: InletStructureControl = { + valueFor: (section) => section.design?.inlet_structure ?? "auto", + adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }), + set: () => undefined, + updateAdjust: () => undefined, + resetAdjust: () => undefined, +}; + +/** 한 측점의 구조물을 오프스크린 SVG에 그린다. 그리는 순서는 B06 카드와 같다. */ +function drawStructures( + svg: SVGElement, + section: CrossSection, + sections: CrossSection[], + x: (offset: number) => number, + y: (elevation: number) => number, +): void { + const design = section.design; + if (!design) return; + const designZAt = (chainageM: number): number | null => { + const found = sections.find( + (item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M, + ); + return found?.design?.design_elevation_m ?? null; + }; + const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt); + const culvertLayout = computeCardCulvert( + section, + section.samples, + null, + revetOffset, + inletStructure, + extraWalls, + link, + ); + const boxLayout = computeBoxLayout(section, section.samples, { + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) }, + }); + const fordLayout = computeFordLayout(section, section.samples, { + inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) }, + outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) }, + }); + + appendFordPavementOverlay(svg, section.ford_pavement, design, x, y); + // 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다. + if (!section.culvert && !link) { + appendRevetmentOverlay(svg, computeRevetmentLayout(section, design.revet_adjust?.own), x, y); + } + if (boxLayout) appendBoxOverlay(svg, boxLayout, x, y); + if (fordLayout) appendFordOverlay(svg, fordLayout, x, y); + if (culvertLayout) { + const linked = !section.culvert && !!link; + appendCulvertOverlay( + svg, + culvertLayout, + x, + y, + undefined, + linked || culvertLayout.culvert.hidden_pipe === true, + ); + } +} + +/** + * 도면에 구조물 엔티티를 얹는다 (제자리 수정). 배치 메타가 없거나 자료를 못 읽으면 + * 아무것도 하지 않는다 — 구조물이 빠져도 도면 자체는 열려야 한다. + */ +export async function appendStructureEntities( + projectId: string, + routeId: number, + drawing: { entities: Record[]; cross_placements?: CrossPlacement[] }, +): Promise { + const placements = drawing.cross_placements ?? []; + if (!placements.length) return 0; + let sections: CrossSection[]; + try { + sections = (await fetchSectionDetail(projectId, routeId)).cross_sections; + } catch { + return 0; + } + + let added = 0; + for (const placement of placements) { + const section = sections.find( + (item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M, + ); + if (!section) continue; + if (!section.culvert && !section.ford && !section.box && !section.ford_pavement) { + // 연동으로 옆에서 이어져 온 기슭막이는 세트가 없어도 그려야 하므로 정본만 더 본다. + if (!section.revetment && !sections.some((item) => item.culvert)) continue; + } + const x = (offset: number): number => offset * placement.mm_per_m + placement.ox; + const y = (elevation: number): number => + -((elevation - placement.dy) * placement.mm_per_m + placement.oy); + const svg = document.createElementNS(SVG_NS, "svg"); + try { + drawStructures(svg, section, sections, x, y); + } catch { + continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. + } + const entities = harvest(svg); + drawing.entities.push(...entities); + added += entities.length; + } + return added; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 4dd16ebc..85269908 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -39,6 +39,7 @@ import { type DesignDrawingResponse, type QuantityTable, } from "./B07_DesignDetail_Api_Fetch"; +import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ interface DesignMeta { @@ -370,6 +371,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { cadHost.dataset.loading = "true"; try { const response = await fetchDesignDrawing(projectId, drawing.id); + // 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어 + // 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다. + if (drawing.kind === "cross" && !response.confirmed) { + await appendStructureEntities(projectId, response.route_id, response.drawing); + } currentDrawing = drawing; currentIndex = index; currentConfirmed = response.confirmed;