/* ============================================================================= * B06_Section_UI_Cross_Detail_Geom.ts * [상세] 그래프 고르기 기하(209-1) — 선을 만나는 점에서 조각으로 나눔 · 조각 길 · 길이 · 닫힌 넓이. * * 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py` * (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) · * 거울 시험 `resources/tester/test_209_1_detail_select.py`. * 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산. * ========================================================================== */ import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; export type Pt = [number, number]; export type LineKind = CrossPickPiece["line"]; /** 고를 수 있는 조각 — 어느 선 · 그 선 위 꺾은선(m). */ export interface SplitPiece { line: LineKind; points: Pt[]; } /** 서버 `LINE_TOLERANCE_M` 과 같은 값 — 두 끝이 선에서 이만큼 안이면 「선 위」 · 고리 끝 맞음. */ export const LINE_TOLERANCE_M = 0.05; /** 다른 선 끝이 이만큼 안이면 닿음으로 보고 자름(m) — 설계선 표고 0.1 mm 반올림. */ const TOUCH_M = 0.01; /** 이보다 짧은 조각은 버림(m) — 누를 수도 없음. */ const MIN_PIECE_M = 0.02; const dist = (a: Pt, b: Pt): number => Math.hypot(a[0] - b[0], a[1] - b[1]); /* ── 서버 짝(검산 · 값) ─────────────────────────────────────────────────── */ /** 원지반선 — 무효 표고 샘플마다 끊은 꺾은선들(서버 `ground_runs` · 카드 `appendGroundProfile`). */ export function groundRuns(samples: ReadonlyArray | undefined): Pt[][] { const runs: Pt[][] = [[]]; for (const sample of samples ?? []) { const x = sample.offset_m; const y = sample.elevation_m ?? sample.z; if (sample.valid === false || typeof x !== "number" || typeof y !== "number") runs.push([]); else runs[runs.length - 1].push([x, y]); } return runs.filter((run) => run.length > 1); } const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) => (raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite)); /** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */ export function pickLines( design: CrossDesign | undefined, samples: ReadonlyArray | undefined, ): Record { return { design: [polyline(design?.design_line)], ground: groundRuns(samples), spoil: [polyline(design?.spoil_fill_line)], }; } /** 선 위 자리(앞 끝에서 잰 길이 m) — 허용 안 가장 가까운 곳 · 없으면 null. */ function locate(point: Pt, run: Pt[]): number | null { let best: [number, number] | null = null; let along = 0; for (let i = 0; i + 1 < run.length; i++) { const [ax, ay] = run[i]; const [bx, by] = run[i + 1]; const dx = bx - ax; const dy = by - ay; const size = Math.hypot(dx, dy); const raw = size === 0 ? 0 : ((point[0] - ax) * dx + (point[1] - ay) * dy) / size ** 2; const t = Math.max(0, Math.min(1, raw)); const gap = Math.hypot(point[0] - (ax + t * dx), point[1] - (ay + t * dy)); if (gap <= LINE_TOLERANCE_M && (best === null || gap < best[0])) best = [gap, along + t * size]; along += size; } return best ? best[1] : null; } function at(run: Pt[], s: number): Pt { for (let i = 0; i + 1 < run.length; i++) { const [ax, ay] = run[i]; const [bx, by] = run[i + 1]; const size = Math.hypot(bx - ax, by - ay); if (s <= size) { const t = size === 0 ? 0 : s / size; return [ax + t * (bx - ax), ay + t * (by - ay)]; } s -= size; } return run[run.length - 1]; } /** 선 위 두 자리 사이 꺾은선(s0 → s1 차례) — 사이 꼭짓점 포함(서버 `sub_path`). */ export function subPath(run: Pt[], s0: number, s1: number): Pt[] { const lo = Math.min(s0, s1); const hi = Math.max(s0, s1); const inner: Pt[] = []; let along = 0; for (let i = 0; i + 1 < run.length; i++) { along += dist(run[i], run[i + 1]); if (lo < along && along < hi) inner.push(run[i + 1]); } const path = [at(run, lo), ...inner, at(run, hi)]; return s0 <= s1 ? path : path.reverse(); } /** 고른 조각 → 그 선 위 꺾은선 · 두 끝이 선 밖이면 null(구조물 선 = 두 끝 직선 · 검산 없음). */ export function piecePath(piece: CrossPickPiece, lines: Record): Pt[] | null { if (piece.line === "structure") return [piece.start, piece.end]; for (const run of lines[piece.line] ?? []) { const s0 = locate(piece.start, run); const s1 = locate(piece.end, run); if (s0 !== null && s1 !== null) return subPath(run, s0, s1); } return null; } export const pathLength = (path: Pt[]): number => path.slice(1).reduce((sum, p, i) => sum + dist(path[i], p), 0); const near = (a: Pt, b: Pt): boolean => dist(a, b) <= LINE_TOLERANCE_M; /** 조각 길들을 끝끼리 이어 한 고리가 되면 그 넓이(㎡) · 안 닫힘 · 남는 조각 = null(서버 `ring_area`). */ export function ringArea(paths: Pt[][]): number | null { if (!paths.length) return null; const ring = [...paths[0]]; const rest = paths.slice(1).map((path) => [...path]); while (rest.length) { const tail = ring[ring.length - 1]; const index = rest.findIndex((p) => near(p[0], tail) || near(p[p.length - 1], tail)); if (index < 0) return null; const [path] = rest.splice(index, 1); ring.push(...(near(path[0], tail) ? path : path.reverse()).slice(1)); } if (ring.length < 4 || !near(ring[0], ring[ring.length - 1])) return null; ring.push(ring[0]); // 끝이 허용 안에서만 맞음 — 닫는 변을 꼭 넣음 let twice = 0; for (let i = 0; i + 1 < ring.length; i++) twice += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]; const area = Math.abs(twice) / 2; return area > 1e-9 ? area : null; } /** 고른 조각들 값(서버 `cell_value` 조각 갈래) — 길이 합 · 닫힌 넓이 · 못 셈 = null. */ export function piecesValue( kind: "length" | "area", pieces: CrossPickPiece[], lines: Record, ): number | null { const paths = pieces.map((piece) => piecePath(piece, lines)); if (!paths.length || paths.some((path) => path === null)) return null; const valid = paths as Pt[][]; return kind === "length" ? valid.reduce((sum, p) => sum + pathLength(p), 0) : ringArea(valid); } /* ── 나눔(화면 몫) ───────────────────────────────────────────────────────── */ const cross = (a: Pt, b: Pt): number => a[0] * b[1] - a[1] * b[0]; const sub = (a: Pt, b: Pt): Pt => [a[0] - b[0], a[1] - b[1]]; /** 점이 선분 a→b 위 어디(0~1)에 닿나 — `TOUCH_M` 밖이면 null. */ function touchAt(point: Pt, a: Pt, b: Pt): number | null { const d = sub(b, a); const size2 = d[0] ** 2 + d[1] ** 2; if (size2 === 0) return null; const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * d[0] + (point[1] - a[1]) * d[1]) / size2)); return dist(point, [a[0] + t * d[0], a[1] + t * d[1]]) <= TOUCH_M ? t : null; } /** * 선들을 서로 만나는 점(교차 · 다른 선 끝이 닿는 점)에서 조각으로 나눔. * 자르는 자리 = 꺾은선 위치(선분 번호 + 0~1). 같은 선끼리는 안 자름. * ponytail: 겹쳐 나란한 선분(같은 직선 위)은 교점 없음으로 봄 — 끝 닿음만 자름. */ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] { const out: SplitPiece[] = []; lines.forEach((line, li) => { const cuts: number[] = []; const pts = line.points; for (let i = 0; i + 1 < pts.length; i++) { const p = pts[i]; const r = sub(pts[i + 1], p); lines.forEach((other, oi) => { if (oi === li) return; const q = other.points; for (let j = 0; j + 1 < q.length; j++) { const s = sub(q[j + 1], q[j]); const denom = cross(r, s); if (Math.abs(denom) > 1e-12) { const t = cross(sub(q[j], p), s) / denom; const u = cross(sub(q[j], p), r) / denom; if (t >= 0 && t <= 1 && u >= 0 && u <= 1) cuts.push(i + t); } } for (const end of [q[0], q[q.length - 1]]) { const t = touchAt(end, p, pts[i + 1]); if (t !== null) cuts.push(i + t); } }); } const at = (c: number): Pt => { const i = Math.min(Math.floor(c), pts.length - 2); const t = c - i; return [ pts[i][0] + t * (pts[i + 1][0] - pts[i][0]), pts[i][1] + t * (pts[i + 1][1] - pts[i][1]), ]; }; const marks = [0, ...cuts.sort((a, b) => a - b), pts.length - 1]; for (let k = 0; k + 1 < marks.length; k++) { const [c0, c1] = [marks[k], marks[k + 1]]; const inner = pts.slice(Math.floor(c0) + 1, Math.ceil(c1)); const points = [at(c0), ...inner, at(c1)]; if (pathLength(points) >= MIN_PIECE_M) out.push({ line: line.line, points }); } }); return out; } /** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */ export function pieceSource(piece: SplitPiece): CrossPickPiece { const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4]; return { line: piece.line, start: round(piece.points[0]), end: round(piece.points[piece.points.length - 1]), }; }