/* ============================================================================= * 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; }