/* ============================================================================= * 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 { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store"; import { computeStoredLayouts as computeLayouts, type StoredLayouts, } from "../B06_Section/B06_Section_Structure_Layouts"; import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box"; import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford"; import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; import { appendCrossDesignOverlay, appendPavementOverlay, } from "../B06_Section/B06_Section_UI_Cross_Design"; import { appendRevetmentOverlay } 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; /** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */ frame?: number[]; } const SVG_NS = "http://www.w3.org/2000/svg"; /** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */ const STRUCTURE_LAYER_ID = "b08-structure"; const STRUCTURE_COLOR = "#f6d55c"; /** 설계선 도면층·색 — 서버 도면(`B07_DesignDetail_Engine_Cad`)이 쓰는 그 값이다. */ const DESIGN_LAYER_ID = "b08-design"; const DESIGN_COLOR = "#b794f6"; /** 글자 크기(종이 mm) — SVG는 CSS로 크기를 잡아 오프스크린에서는 읽을 수 없다. */ const LABEL_FONT_MM = 2.0; /** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ const CHAINAGE_TOLERANCE_M = 0.02; /** 정의부(해칭 패턴·클립)는 도형이 아니다 — 클립 경계 자체는 그림이 아니라 잘라 낼 자다. */ const SKIP_SELECTOR = "defs, clipPath, pattern"; /** 원을 폴리선으로 바꿀 때 쓰는 분할 수 — 클립 안에서만 쓴다(밖은 Circle 그대로). */ const CIRCLE_SEGMENTS = 36; type Entity = Record; type XY = [number, number]; /** 수확한 도형을 어느 도면층·색으로 넣을지. */ interface Style { layerId: string; color: string; } const STRUCTURE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: STRUCTURE_COLOR }; const DESIGN_STYLE: Style = { layerId: DESIGN_LAYER_ID, color: DESIGN_COLOR }; function baseEntity(style: Style, type: string, shapeData: unknown): Entity { return { id: crypto.randomUUID(), type, lineColor: style.color, lineWidth: 1, layerId: style.layerId, shapeData, }; } function lineEntity(style: Style, start: XY, end: XY): Entity { return baseEntity(style, "Line", { startPoint: { x: start[0], y: start[1] }, endPoint: { x: end[0], y: end[1] }, }); } /** 점열 → PolyLine(자식 Line 묶음). 서버 도면의 폴리라인과 같은 직렬화다. */ function polyEntity(style: Style, 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(style, points[index], points[index + 1])); } const poly = baseEntity(style, "PolyLine", null); poly.children = children; return poly; } function textEntity(style: Style, label: string, at: XY, align: string): Entity { return baseEntity(style, "Text", { label, basePoint: { x: at[0], y: at[1] }, options: { textDirection: { x: 1, y: 0 }, textAlign: align, textColor: style.color, fontSize: LABEL_FONT_MM, fontFamily: "sans-serif", }, }); } /** * 공선점 제거(Ramer–Douglas–Peucker) — 설계선은 지반 샘플마다 점을 갖고 있어(측점 하나에 * 83점) 곧은 사면이 수십 조각으로 쪼개진다. CAD에서 한 변은 한 선이어야 잡고 고칠 수 있다. * 허용 오차는 **종이 mm**라, 1/100에서 0.05mm = 실거리 5mm — 도면상 같은 직선이다. */ function simplify(points: XY[], tolerance = 0.05): XY[] { if (points.length < 3) return points; const first = points[0]; const last = points[points.length - 1]; const [dx, dy] = [last[0] - first[0], last[1] - first[1]]; const span = Math.hypot(dx, dy); let worst = 0; let index = 0; for (let i = 1; i < points.length - 1; i += 1) { const [px, py] = points[i]; const distance = span > 1e-9 ? Math.abs(dy * px - dx * py + last[0] * first[1] - last[1] * first[0]) / span : Math.hypot(px - first[0], py - first[1]); if (distance > worst) { worst = distance; index = i; } } if (worst <= tolerance) return [first, last]; return [ ...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance), ]; } /** * 끝점이 맞물리는 선분들을 하나의 점열로 잇는다. * * B06 설계선 그리기는 **점 사이마다 `` 하나**를 만든다(_Cross_Design 603행) — * 그대로 옮기면 곧은 사면 한 변이 CAD에서 선 열댓 개가 된다. 이어 붙인 뒤 공선점을 * 지우면 한 변이 한 선이 된다(2026-08-30 사용자 지적). */ function chain(segments: XY[][], tolerance = 1e-3): XY[][] { const used = new Array(segments.length).fill(false); const near = (a: XY, b: XY): boolean => Math.abs(a[0] - b[0]) <= tolerance && Math.abs(a[1] - b[1]) <= tolerance; const chains: XY[][] = []; for (let seed = 0; seed < segments.length; seed += 1) { if (used[seed]) continue; used[seed] = true; const points: XY[] = [segments[seed][0], segments[seed][1]]; let grew = true; while (grew) { grew = false; for (let i = 0; i < segments.length; i += 1) { if (used[i]) continue; const [a, b] = segments[i]; const head = points[0]; const tail = points[points.length - 1]; if (near(tail, a)) points.push(b); else if (near(tail, b)) points.push(a); else if (near(head, b)) points.unshift(a); else if (near(head, a)) points.unshift(b); else continue; used[i] = true; grew = true; } } chains.push(points); } return chains; } /** 점열을 종이 x범위로 자른다 — 경계는 선형보간으로 새 점을 만든다(서버 _clip_polyline과 같은 규칙). */ function clipX(points: XY[], x0: number, x1: number): XY[] { const clipped: XY[] = []; for (let index = 0; index < points.length; index += 1) { const [x, y] = points[index]; if (index > 0) { const [px, py] = points[index - 1]; for (const edge of [x0, x1]) { if ((px < edge && edge < x) || (x < edge && edge < px)) { const ratio = (edge - px) / (x - px); clipped.push([edge, py + (y - py) * ratio]); } } } if (x >= x0 && x <= x1) clipped.push([x, y]); } return clipped; } 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; } /** * 이 도형에 걸린 clipPath 폴리곤(도면 좌표). 없으면 null. * * 기슭막이 형태 해칭(`B06_Section_UI_Cross_Wall_Hatch`)은 벽 폴리곤 clip 안에서 그린다. * 종전 수확기는 그 그룹을 통째로 건너뛰어 **돌쌓기·콘크리트 해칭이 CAD 도면에 하나도 * 실리지 않았다**. 클립을 쓰는 대신 경계로 **잘라서** 싣는다(2026-09-03 사용자 결정). */ function clipPolygonOf(element: SVGElement): XY[] | null { const group = element.closest("g[clip-path]"); const reference = group?.getAttribute("clip-path") ?? ""; const id = reference.match(/url\(#([^)]+)\)/)?.[1]; if (!id) return null; const shape = element.ownerDocument?.getElementById(id)?.querySelector("polygon"); if (!shape) return null; const points = parsePoints(shape as unknown as SVGElement); return points.length >= 3 ? points : null; } /** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */ function pointInPolygon(point: XY, polygon: XY[]): boolean { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { const [xi, yi] = polygon[i]; const [xj, yj] = polygon[j]; const straddles = yi > point[1] !== yj > point[1]; if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside; } return inside; } /** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */ function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] { const [x0, y0] = start; const [x1, y1] = end; const dx = x1 - x0; const dy = y1 - y0; const cuts = [0, 1]; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { const [ex, ey] = polygon[j]; const [fx, fy] = polygon[i]; const denominator = dx * (fy - ey) - dy * (fx - ex); if (Math.abs(denominator) < 1e-12) continue; const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator; const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator; if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t); } cuts.sort((a, b) => a - b); const runs: XY[][] = []; for (let index = 0; index + 1 < cuts.length; index += 1) { const from = cuts[index]; const to = cuts[index + 1]; if (to - from < 1e-9) continue; const mid = (from + to) / 2; if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue; runs.push([ [x0 + dx * from, y0 + dy * from], [x0 + dx * to, y0 + dy * to], ]); } return runs; } /** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */ function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] { const runs: XY[][] = []; for (let index = 0; index + 1 < points.length; index += 1) { runs.push(...clipSegment(points[index], points[index + 1], polygon)); } return runs; } function circlePoints(center: XY, radius: number): XY[] { const points: XY[] = []; for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) { const angle = (2 * Math.PI * index) / CIRCLE_SEGMENTS; points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]); } return points; } /** * `rect` → 닫힌 네 모서리 점열(회전 transform 적용). * * 돌쌓기 돌·돌망태 칸(`B06_Section_UI_Cross_Wall_Hatch.cell()`)은 `rect` 하나에 * `rotate(벽기울기 cx cy)` 를 걸어 그린다. 수확기가 `rect` 를 안 보던 때는 형태 해칭 * 80개 중 **49개(돌·칸 전부)가 CAD 에 안 실렸다**(2026-09-03 실측). 모서리 반경(`rx`)은 * 무시한다 — 도면에서는 각진 칸으로 충분하다. */ function rectPoints(element: SVGElement): XY[] { const x = attr(element, "x"); const y = attr(element, "y"); const width = attr(element, "width"); const height = attr(element, "height"); const corners: XY[] = [ [x, y], [x + width, y], [x + width, y + height], [x, y + height], [x, y], ]; const rotate = /rotate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)/.exec( element.getAttribute("transform") ?? "", ); if (!rotate) return corners.map(([cx, cy]) => flip(cx, cy)); const angle = (Number(rotate[1]) * Math.PI) / 180; const [ox, oy] = [Number(rotate[2]), Number(rotate[3])]; const cos = Math.cos(angle); const sin = Math.sin(angle); return corners.map(([cx, cy]) => { const dx = cx - ox; const dy = cy - oy; return flip(ox + dx * cos - dy * sin, oy + dx * sin + dy * cos); }); } /** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] { const [fx0, , fx1] = frame; const entities: Entity[] = []; const segments: XY[][] = []; const inside = (px: number): boolean => px >= fx0 && px <= fx1; const nodes = root.querySelectorAll("polygon, polyline, line, circle, rect, text"); for (const element of Array.from(nodes)) { if (element.closest(SKIP_SELECTOR)) continue; const tag = element.tagName.toLowerCase(); // 클립 그룹 안의 해칭은 경계로 **잘라서** 싣는다 — CAD 에는 클립이 없다. const clip = clipPolygonOf(element); if (tag === "polygon" || tag === "polyline" || tag === "rect") { const points = tag === "rect" ? rectPoints(element) : parsePoints(element); if (tag === "polygon" && points.length > 2) points.push(points[0]); const runs = clip ? clipPointsToPolygon(points, clip) : [points]; for (const run of runs) { const poly = polyEntity(style, simplify(clipX(run, fx0, fx1))); if (poly) entities.push(poly); } } else if (tag === "line") { const start = flip(attr(element, "x1"), attr(element, "y1")); const end = flip(attr(element, "x2"), attr(element, "y2")); for (const run of clip ? clipSegment(start, end, clip) : [[start, end]]) { const cut = clipX(run, fx0, fx1); if (cut.length === 2) segments.push(cut); } } else if (tag === "circle") { const center = flip(attr(element, "cx"), attr(element, "cy")); if (!inside(center[0])) continue; const radius = attr(element, "r"); if (!clip) { entities.push( baseEntity(style, "Circle", { center: { x: center[0], y: center[1] }, radius, }), ); continue; } // 클립 안 원(통나무 마구리 등)은 폴리선으로 바꿔 경계에서 자른다. for (const run of clipPointsToPolygon(circlePoints(center, radius), clip)) { const poly = polyEntity(style, simplify(clipX(run, fx0, fx1))); if (poly) entities.push(poly); } } else if (tag === "text") { const label = (element.textContent ?? "").trim(); const at = flip(attr(element, "x"), attr(element, "y")); if (!label || !inside(at[0])) continue; if (clip && !pointInPolygon(at, clip)) continue; const anchor = element.getAttribute("text-anchor"); const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center"; entities.push(textEntity(style, label, at, align)); } } // 낱개 선분은 이어 붙인 뒤 공선점을 지워 한 변을 한 선으로 만든다. for (const points of chain(segments)) { const poly = polyEntity(style, simplify(points)); if (poly) entities.push(poly); } return entities; } /** 엔티티가 차지하는 종이 좌표 사각형 (없으면 null). */ function entityBox(entity: Record): number[] | null { const xs: number[] = []; const ys: number[] = []; const walk = (node: Record): void => { const shape = node.shapeData as Record> | null; for (const key of ["startPoint", "endPoint", "basePoint", "center"]) { const point = shape?.[key]; if (point) { xs.push(point.x); ys.push(point.y); } } for (const child of (node.children as Record[]) ?? []) walk(child); }; walk(entity); return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null; } type Layouts = StoredLayouts; /** * 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(designTrim)을 B06 카드와 같은 * 우선순위로 넘긴다 — 정본에는 트림 전 원본만 있어서, 이걸 안 태우면 벽이 서 있어도 * 설계선이 원래대로 지나간다(2026-08-30 사용자 지적). */ function drawDesign( svg: SVGElement, section: CrossSection, layouts: Layouts, x: (offset: number) => number, y: (elevation: number) => number, ): void { const { design, culvert, box, ford, own } = layouts; const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y); if (!paved) appendPavementOverlay(svg, design, x, y); appendCrossDesignOverlay( svg, design, x, y, section.samples, culvert?.designTrim ?? ford?.designTrim ?? box?.designTrim ?? own?.designTrim, ); } /** 구조물을 그린다. 그리는 순서는 B06 카드와 같다. */ function drawStructures( svg: SVGElement, section: CrossSection, layouts: Layouts, x: (offset: number) => number, y: (elevation: number) => number, ): void { const { link, culvert, box, ford, own } = layouts; if (own) appendRevetmentOverlay(svg, own, x, y); if (box) appendBoxOverlay(svg, box, x, y); if (ford) appendFordOverlay(svg, ford, x, y); if (culvert) { const linked = !section.culvert && !!link; appendCulvertOverlay( svg, culvert, x, y, undefined, linked || culvert.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 { // B05·B06과 같은 공유 캐시를 쓴다 — 도면마다 새로 받으면 장을 넘길 때마다 // 종횡단 상세를 다시 내려받아 0.2초씩 더 걸린다(2026-08-30 실측). sections = (await loadSectionDetail(projectId, routeId)).cross_sections; } catch { return 0; } let added = 0; const fresh: Entity[] = []; const replaced: number[][] = []; for (const placement of placements) { const section = sections.find( (item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M, ); const frame = placement.frame; if (!section || !frame) 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); let entities: Entity[]; try { const layouts = computeLayouts(section, sections); if (!layouts) continue; const designSvg = document.createElementNS(SVG_NS, "svg"); drawDesign(designSvg, section, layouts, x, y); const structureSvg = document.createElementNS(SVG_NS, "svg"); drawStructures(structureSvg, section, layouts, x, y); entities = [ ...harvest(designSvg, DESIGN_STYLE, frame), ...harvest(structureSvg, STRUCTURE_STYLE, frame), ]; } catch { continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. } if (!entities.length) continue; // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. replaced.push(frame); fresh.push(...entities); added += entities.length; } if (replaced.length) { // 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다. drawing.entities = drawing.entities.filter((entity) => { if (entity.layerId !== DESIGN_LAYER_ID) return true; const box = entityBox(entity); if (!box) return true; return !replaced.some( ([fx0, fy0, fx1, fy1]) => box[0] >= fx0 && box[2] <= fx1 && box[1] >= fy0 && box[3] <= fy1, ); }); } drawing.entities.push(...fresh); return added; }