Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
329 lines
14 KiB
TypeScript
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]),
|
|
};
|
|
}
|