Files
Aislo/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts
T
eomsangdonandClaude Opus 5.5 10eb54f5e5 feat(B06): [상세] 그래프 고르기 — 교차점 분할 · 여러 고름(원지반 포함) · 닫힌 넓이 · 고른 것 값 · 서버 조각 검산 (209-1)
- 선(설계 · 구조물 · 원지반 · 사토장)을 만나는 점에서 조각으로 나눔 · 조각마다 판정 선
- 누름 = 하나 · Shift/Ctrl+누름 = 더하기/빼기 · [+ − ⤢] 왼쪽 값 두 줄(길이 합 · 면적 · 닫히지 않음)
- 칸 넣기 = 우클릭 고르기 길 그대로 · 길이 한 번 누름 = 바로 · 여럿 = Enter · [칸에 넣기]
- 저장 = 조각 두 끝 목록(pieces) · 서버가 설계선 · 원지반선 · 사토장선 위 검산 · 구조물 선은 두 끝 직선 · 옛 두 끝 꼴 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-10 15:21:50 +09:00

228 lines
9.5 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`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
* ========================================================================== */
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<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));
/** 서버가 아는 선(서버 `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)],
};
}
/** 선 위 자리(앞 끝에서 잰 길이 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 });
}
});
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]),
};
}