Files
Aislo/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts
T

329 lines
14 KiB
TypeScript

/* =============================================================================
* 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`) · 닫힌 영역(`findFaces` · 259-2)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
* ========================================================================== */
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[];
/** 구조물 선 하나(295-3) — 나눈 조각이 같은 번호면 고를 때 한 묶음(닫힌 영역 · 저장은 조각대로). */
group?: number;
}
/** 서버 `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<SectionSample> | 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));
/** 암 경계선 — 원지반선 + 오프셋(암 프리셋만 · 카드 `appendRockBoundaryOverlay` · 서버 `rock_runs`). */
export function rockRuns(
design: CrossDesign | undefined,
samples: ReadonlyArray<SectionSample> | undefined,
): Pt[][] {
const offset = design?.rock_boundary_offset_m;
if (design?.geometry_preset !== "rock" || typeof offset !== "number") return [];
return groundRuns(samples).map((run) => run.map(([x, y]): Pt => [x, y + offset]));
}
/** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */
export function pickLines(
design: CrossDesign | undefined,
samples: ReadonlyArray<SectionSample> | undefined,
): Record<string, Pt[][]> {
return {
design: [polyline(design?.design_line)],
ground: groundRuns(samples),
spoil: [polyline(design?.spoil_fill_line)],
rock: rockRuns(design, samples),
};
}
/** 선 위 자리(앞 끝에서 잰 길이 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<string, Pt[][]>): 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<string, Pt[][]>,
): 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,
...(line.group !== undefined && { group: line.group }),
});
}
});
return out;
}
/* ── 닫힌 영역(259-2 · 화면 몫) ─────────────────────────────────────────── */
/** 나눈 조각으로 둘러싸인 면 하나 — 둘레 조각 번호 · 고리(m) · 넓이(㎡). */
export interface Face {
edges: number[];
ring: Pt[];
area: number;
}
/** 꺾은선이 그 끝에서 떠나는 방향(라디안) — 아주 짧은 첫 토막은 건너뜀. */
function leaving(points: Pt[]): number {
const [x0, y0] = points[0];
const next = points.find((p) => dist(p, points[0]) > 1e-6) ?? points[points.length - 1];
return Math.atan2(next[1] - y0, next[0] - x0);
}
/**
* 조각(변)으로 된 평면 그래프의 면 — 끝이 `TOUCH_M` 안이면 같은 마디 · 열린 끝 조각(마디 차수 1)은
* 거듭 지움(열린 영역 제외) · 각 반변에서 도착 마디의 시계 방향 다음 변으로 돌아 면을 셈 ·
* 넓이가 양(반시계)인 것만 = 바깥 면 뺌.
* ponytail: 안에 떠 있는 섬(다리 없음)은 바깥 면의 구멍으로 안 뺌 — 필요하면 포함 판정 더함.
*/
export function findFaces(pieces: SplitPiece[]): Face[] {
const nodes: Pt[] = [];
const nodeOf = (p: Pt): number => {
const found = nodes.findIndex((q) => dist(p, q) <= TOUCH_M);
return found >= 0 ? found : nodes.push(p) - 1;
};
const ends = pieces.map((piece) => [
nodeOf(piece.points[0]),
nodeOf(piece.points[piece.points.length - 1]),
]);
const alive = ends.map(([a, b]) => a !== b);
for (let changed = true; changed;) {
const degree = new Array<number>(nodes.length).fill(0);
ends.forEach(([a, b], i) => alive[i] && (degree[a]++, degree[b]++));
changed = false;
ends.forEach(([a, b], i) => {
if (alive[i] && (degree[a] < 2 || degree[b] < 2)) [alive[i], changed] = [false, true];
});
}
// 반변 h = 2i(앞 → 뒤) · 2i+1(뒤 → 앞)
const path = (h: number): Pt[] => {
const points = pieces[h >> 1].points;
return h & 1 ? [...points].reverse() : points;
};
const head = (h: number): number => ends[h >> 1][h & 1 ? 0 : 1];
const out = nodes.map(() => [] as Array<[number, number]>);
ends.forEach(([a, b], i) => {
if (!alive[i]) return;
out[a].push([leaving(path(2 * i)), 2 * i]);
out[b].push([leaving(path(2 * i + 1)), 2 * i + 1]);
});
out.forEach((list) => list.sort((p, q) => p[0] - q[0]));
const next = (h: number): number => {
const list = out[head(h)];
const at = list.findIndex(([, e]) => e === (h ^ 1));
return list[(at - 1 + list.length) % list.length][1];
};
const faces: Face[] = [];
const seen = new Set<number>();
ends.forEach((_, i) => {
if (!alive[i]) return;
for (const start of [2 * i, 2 * i + 1]) {
if (seen.has(start)) continue;
const cycle: number[] = [];
for (let h = start; !seen.has(h) && cycle.length <= 2 * pieces.length; h = next(h)) {
seen.add(h);
cycle.push(h);
}
const ring = cycle.flatMap((h) => path(h).slice(1));
let twice = 0;
ring.forEach((p, k) => {
const q = ring[(k + 1) % ring.length];
twice += p[0] * q[1] - q[0] * p[1];
});
if (twice / 2 > 1e-9)
faces.push({ edges: [...new Set(cycle.map((h) => h >> 1))], ring, area: twice / 2 });
}
});
return faces;
}
/** 조각 → 저장 꼴(두 끝 · 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]),
};
}