fix(B05): 구조물 탑뷰 풋프린트를 링별 편거리 병합으로 재구현

기하 합집합(`_Corridor_Union.ts`)이 겹친 스트립에서 무너졌다. "같은 토막이
짝수 번 나오면 버린다"는 내부 칸막이 제거 규칙이 **외곽의 공유 변까지** 지웠다.
4+4.3(84.3m)에서 집수정 두 단은 탑뷰 사각형이 완전히 같아 네 변 전부 소멸해
좌측 커브가 0개였고, 기슭막이 두 단은 안쪽 변 −1.98을 공유해 23칸이 소멸,
0.34×0.92m 쪼가리만 남았다.

합집합을 버리고 구조물이 스윕 솔리드라는 성질을 그대로 쓴다 — 링 경로가 같은
부재끼리 묶고, 링마다 편거리 창을 도로 밖으로 자른 뒤 1차원 병합해 레인으로
잇는다. 기하 불리언이 없어 퇴화 케이스가 원리적으로 없다.

- `_Corridor_Plan_Footprint.ts` 신규 — 위 알고리즘.
- `_Corridor_Plan.ts` — `structureLoops` 제거, 신규 모듈 호출.
- `_Corridor_Carve.ts` — 죽은 `outlineLoops()` 제거(`outerAt`은 유지).
- `_Corridor_Union.ts` — 죽은 `unionOutline`·`insideQuad`·`splitEdges` 제거.
  `UnionQuad`/`unionQuadOf`는 `outerAt`이 계속 쓴다. 189 → 34줄.

실측: 커브 각 점을 가장 가까운 링 횡단선에 되투영해 편거리를 읽었다 — 링선
이탈 최대 0.0000m. 84.3 좌(집수정) 2.000~2.650 31점, 우(기슭막이) −3.250~−2.000
47점으로 전 링이 살아난다. 275.71·149.73·200.92 세트도 날개벽까지 각각 나온다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 17:18:47 +09:00
co-authored by Claude Opus 5
parent 25ae04b2cd
commit b29113ba22
4 changed files with 191 additions and 221 deletions
+1 -13
View File
@@ -33,7 +33,7 @@ import {
} from "./B05_Profile_UI_Corridor_Station"; } from "./B05_Profile_UI_Corridor_Station";
import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build";
import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve";
import { unionOutline, unionQuadOf } from "./B05_Profile_UI_Corridor_Union"; import { unionQuadOf } from "./B05_Profile_UI_Corridor_Union";
import type { PXY, UnionQuad as FootprintQuad } from "./B05_Profile_UI_Corridor_Union"; import type { PXY, UnionQuad as FootprintQuad } from "./B05_Profile_UI_Corridor_Union";
/** 풋프린트가 노견보다 이만큼은 밖으로 나가야 자를 값어치가 있다(m). */ /** 풋프린트가 노견보다 이만큼은 밖으로 나가야 자를 값어치가 있다(m). */
@@ -81,18 +81,6 @@ export class CarveFootprint {
this.strips.push(edges.map((edge) => [edge[0], edge[1]])); this.strips.push(edges.map((edge) => [edge[0], edge[1]]));
} }
/**
* **최외곽 윤곽만**(2026-08-25 사용자: 커브들이 이어져 있으면 최외곽만) — 담긴
* 사각형 전체의 **합집합 경계**를 낸다. 서로 겹치거나 맞닿은 조각은 하나의 외곽선이
* 되고, 내부 칸막이·격자는 사라진다.
*
* 방법: 모든 변을 서로의 교점에서 잘라 토막 낸 뒤, **다른 사각형 안에 들어간
* 토막을 버리고**, 남은 토막을 끝점끼리 이어 닫힌 고리로 만든다.
*/
outlineLoops(): PXY[][] {
return unionOutline(this.quads);
}
/** /**
* 행 횡단선(원점 c, 좌향 단위벡터 u — 편거리 o의 자리 = c + u·o)이 풋프린트와 * 행 횡단선(원점 c, 좌향 단위벡터 u — 편거리 o의 자리 = c + u·o)이 풋프린트와
* 겹치는 **그 측의 가장 바깥 편거리**. 안 겹치면 null. * 겹치는 **그 측의 가장 바깥 편거리**. 안 겹치면 null.
+3 -48
View File
@@ -18,7 +18,8 @@
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { CorridorStructure, RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; import type { CorridorStructure, RouteFrame } from "./B05_Profile_UI_Corridor_Structures";
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
import { assembleStripLoops, CarveFootprint, ringEdges } from "./B05_Profile_UI_Corridor_Carve"; import { assembleStripLoops } from "./B05_Profile_UI_Corridor_Carve";
import { structureFootprintLoops } from "./B05_Profile_UI_Corridor_Plan_Footprint";
import { mixedGround, trimSlopeToGround } from "./B05_Profile_UI_Corridor_Station"; import { mixedGround, trimSlopeToGround } from "./B05_Profile_UI_Corridor_Station";
import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve";
import type { PXY } from "./B05_Profile_UI_Corridor_Union"; import type { PXY } from "./B05_Profile_UI_Corridor_Union";
@@ -92,18 +93,6 @@ function at(frame: PlanFrame, offset: number): PXY {
return { x: frame.cx + frame.leftX * offset, y: frame.cy + frame.leftY * offset }; return { x: frame.cx + frame.leftX * offset, y: frame.cy + frame.leftY * offset };
} }
/** 그 측에서 노견 바깥에 남는 편거리 구간 — 도로부를 잘라낸 뒤 폭이 없으면 null. */
function clipOutsideRoad(
edge: readonly [number, number],
side: Side,
roadEdgeOffset: number,
): [number, number] | null {
const [min, max] = edge;
const lo = side === "left" ? Math.max(min, roadEdgeOffset) : min;
const hi = side === "left" ? max : Math.min(max, roadEdgeOffset);
return hi - lo > MIN_WIDTH_M ? [lo, hi] : null;
}
/** 세트 구조물의 최고 표고 — 폴리곤 표고에 링 종단 보정(dz)을 더한 값의 최대. */ /** 세트 구조물의 최고 표고 — 폴리곤 표고에 링 종단 보정(dz)을 더한 값의 최대. */
function topElevationOf(set: ReadonlyArray<CorridorStructure>): number | null { function topElevationOf(set: ReadonlyArray<CorridorStructure>): number | null {
let top: number | null = null; let top: number | null = null;
@@ -124,40 +113,6 @@ function topElevationOf(set: ReadonlyArray<CorridorStructure>): number | null {
return top; return top;
} }
/** 세트 구조물 솔리드의 그 측 탑뷰 외곽 — 도로부를 뺀 링 사이 사각형의 합집합. */
function structureLoops(
set: ReadonlyArray<CorridorStructure>,
side: Side,
roadEdgeOffset: number,
planZ: number,
): Array<Array<[number, number, number]>> {
const footprint = new CarveFootprint();
for (const structure of set) {
// 배관은 구조물 솔리드 윤곽에서 뺀다 — 다른 부재 속을 지나는 부재다.
if (structure.kind === "pipe") continue;
const rings = structure.rings;
if (!rings || rings.length < 2) continue;
const perRing = structure.polygons;
const shared = structure.polygon;
// 링이 끊긴 자리(폴리곤 없음·도로부에 다 잘림)에서 스트립을 나눈다.
let run: Array<[PXY, PXY]> = [];
rings.forEach((frame, index) => {
const edge = ringEdges(perRing?.[index] ?? shared ?? []);
const window = edge ? clipOutsideRoad(edge, side, roadEdgeOffset) : null;
if (!window) {
footprint.addStrip(run);
run = [];
return;
}
run.push([at(frame, window[0]), at(frame, window[1])]);
});
footprint.addStrip(run);
}
return footprint
.outlineLoops()
.map((loop) => loop.map((point) => [point.x, point.y, planZ] as [number, number, number]));
}
/** 스트립 행([안쪽, 바깥쪽] XY)을 평면 표고에 얹어 닫힌 고리로 만든다. */ /** 스트립 행([안쪽, 바깥쪽] XY)을 평면 표고에 얹어 닫힌 고리로 만든다. */
function stripLoops( function stripLoops(
rows: ReadonlyArray<[PXY, PXY] | null>, rows: ReadonlyArray<[PXY, PXY] | null>,
@@ -233,7 +188,7 @@ export function buildPlanCurves(
}; };
(["left", "right"] as const).forEach((side) => { (["left", "right"] as const).forEach((side) => {
push("structure", side, structureLoops(set, side, roadEdgeOf(side), planZ)); push("structure", side, structureFootprintLoops(set, side, roadEdgeOf(side), planZ));
}); });
for (const silhouette of structureSilhouettes(section)) { for (const silhouette of structureSilhouettes(section)) {
@@ -0,0 +1,182 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Plan_Footprint.ts
* 구조물 세트의 **탑뷰 풋프린트 외곽 고리**(2026-08-26 사용자 재구현 지시).
*
* 기하 합집합(옛 `_Corridor_Union.ts`)을 쓰지 않는다 — 겹친 스트립에서 외곽선이
* 무너졌다(같은 토막이 짝수 번 나오면 버리는 규칙이 외곽의 공유 변까지 지웠다).
*
* 대신 구조물이 **스윕 솔리드**라는 성질을 그대로 쓴다:
* ① 링 경로(프레임 중심 열)가 같은 부재끼리 묶는다 — 같은 자리를 지나는 부재는
* 링 번호가 그대로 맞아떨어진다. 날개벽처럼 제 갈 길 가는 부재는 따로 남는다.
* ② 묶음 안에서 **링마다** 부재들의 편거리 창을 도로 밖으로 자르고 1차원 병합.
* ③ 링을 따라 겹치는 구간끼리 이어 레인을 만들고, 레인마다 닫힌 고리 하나.
*
* 1차원 구간 병합이라 퇴화(같은 사각형 겹침·포함 관계·맞닿은 변)가 없다.
* ========================================================================== */
import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures";
/** 안팎 폭이 이만큼은 돼야 커브를 만든다(m). */
const MIN_WIDTH_M = 0.05;
/** 구간 병합·겹침 판정 여유(m). */
const EPS_M = 1e-6;
/** 링 경로 묶음 키의 좌표 눈금(m). */
const PATH_SNAP_M = 1e-3;
type Side = "left" | "right";
/** 편거리 구간 — 항상 `lo <= hi`(부호 있는 편거리 그대로). */
type Span = [number, number];
/** 링 폴리곤이 그 링에서 차지하는 편거리 폭. */
function polygonSpan(polygon: ReadonlyArray<readonly [number, number]> | undefined): Span | null {
if (!polygon || !polygon.length) return null;
let lo = Infinity;
let hi = -Infinity;
for (const [offset] of polygon) {
if (offset < lo) lo = offset;
if (offset > hi) hi = offset;
}
return hi - lo > EPS_M ? [lo, hi] : null;
}
/** 도로부(노견 안쪽)를 잘라낸 나머지 — 폭이 없으면 null. */
function clipOutsideRoad(span: Span, side: Side, roadEdgeOffset: number): Span | null {
const lo = side === "left" ? Math.max(span[0], roadEdgeOffset) : span[0];
const hi = side === "left" ? span[1] : Math.min(span[1], roadEdgeOffset);
return hi - lo > MIN_WIDTH_M ? [lo, hi] : null;
}
/** 구간들을 겹치는 것끼리 합쳐 오름차순 서로소 목록으로. */
function mergeSpans(spans: ReadonlyArray<Span>): Span[] {
if (!spans.length) return [];
const sorted = [...spans].sort((a, b) => a[0] - b[0]);
const merged: Span[] = [[sorted[0][0], sorted[0][1]]];
for (let i = 1; i < sorted.length; i += 1) {
const last = merged[merged.length - 1];
const next = sorted[i];
if (next[0] <= last[1] + EPS_M) last[1] = Math.max(last[1], next[1]);
else merged.push([next[0], next[1]]);
}
return merged;
}
/** 두 구간이 맞닿거나 겹치나. */
function touches(a: Span, b: Span): boolean {
return a[0] <= b[1] + EPS_M && b[0] <= a[1] + EPS_M;
}
/** 편거리를 실좌표로 — 링 프레임의 좌향 벡터를 탄다. */
function at(frame: StructureFrame, offset: number): [number, number] {
return [frame.cx + frame.leftX * offset, frame.cy + frame.leftY * offset];
}
/** 링 경로 묶음 키 — 프레임 중심 열을 1mm 눈금으로 스냅해 이어붙인다. */
function ringPathKey(rings: ReadonlyArray<StructureFrame>): string {
const snap = (value: number): number => Math.round(value / PATH_SNAP_M);
return rings.map((frame) => `${snap(frame.cx)}:${snap(frame.cy)}`).join("|");
}
/** 링 하나에서 이어지는 스트립 한 줄 — 레인. */
interface Lane {
rows: Array<{ frame: StructureFrame; span: Span }>;
}
/**
* 링별 병합 구간 열을 레인으로 잇는다 — 앞 링 구간과 맞닿는 구간이 같은 레인이다.
* 맞닿는 게 없으면 그 레인은 거기서 끝나고, 짝 없는 구간은 새 레인을 연다.
*/
function assembleLanes(
rings: ReadonlyArray<StructureFrame>,
spansPerRing: ReadonlyArray<Span[]>,
): Lane[] {
const lanes: Lane[] = [];
let open: Lane[] = [];
rings.forEach((frame, index) => {
const spans = spansPerRing[index];
const carried: Lane[] = [];
const claimed = new Set<Lane>();
for (const span of spans) {
const lane = open.find(
(candidate) =>
!claimed.has(candidate) && touches(candidate.rows[candidate.rows.length - 1].span, span),
);
if (lane) {
claimed.add(lane);
lane.rows.push({ frame, span });
carried.push(lane);
continue;
}
const fresh: Lane = { rows: [{ frame, span }] };
lanes.push(fresh);
carried.push(fresh);
}
open = carried;
});
return lanes;
}
/** 레인 하나를 닫힌 고리로 — 바깥 가장자리를 앞으로, 안쪽을 뒤로 이어 닫는다. */
function laneLoop(lane: Lane, side: Side, planZ: number): Array<[number, number, number]> | null {
if (lane.rows.length < 2) return null;
const outerOf = (span: Span): number => (side === "left" ? span[1] : span[0]);
const innerOf = (span: Span): number => (side === "left" ? span[0] : span[1]);
const loop: Array<[number, number, number]> = [];
for (const row of lane.rows) {
const [x, y] = at(row.frame, outerOf(row.span));
loop.push([x, y, planZ]);
}
for (let i = lane.rows.length - 1; i >= 0; i -= 1) {
const row = lane.rows[i];
const [x, y] = at(row.frame, innerOf(row.span));
loop.push([x, y, planZ]);
}
loop.push(loop[0]);
return loop;
}
/**
* 구조물 세트의 그 측 탑뷰 외곽 고리들 — 도로부(노견 안쪽)는 뺀다.
*
* 배관은 뺀다 — 다른 부재 속을 지나는 부재라 링 열이 없다.
*/
export function structureFootprintLoops(
set: ReadonlyArray<CorridorStructure>,
side: Side,
roadEdgeOffset: number,
planZ: number,
): Array<Array<[number, number, number]>> {
const groups = new Map<string, CorridorStructure[]>();
for (const structure of set) {
if (structure.kind === "pipe") continue;
const rings = structure.rings;
if (!rings || rings.length < 2) continue;
const key = ringPathKey(rings);
const bucket = groups.get(key);
if (bucket) bucket.push(structure);
else groups.set(key, [structure]);
}
const loops: Array<Array<[number, number, number]>> = [];
for (const members of groups.values()) {
const rings = members[0].rings as StructureFrame[];
const spansPerRing = rings.map((_, index) => {
const spans: Span[] = [];
for (const member of members) {
const polygon = member.polygons?.[index] ?? member.polygon;
const span = polygonSpan(polygon);
if (!span) continue;
const clipped = clipOutsideRoad(span, side, roadEdgeOffset);
if (clipped) spans.push(clipped);
}
return mergeSpans(spans);
});
for (const lane of assembleLanes(rings, spansPerRing)) {
const loop = laneLoop(lane, side, planZ);
if (loop) loops.push(loop);
}
}
return loops;
}
+5 -160
View File
@@ -1,13 +1,11 @@
/* ============================================================================= /* =============================================================================
* B05_Profile_UI_Corridor_Union.ts * B05_Profile_UI_Corridor_Union.ts
* 볼록 사각형 무리의 **합집합 경계**(최외곽 윤곽) — 2026-08-25 사용자: 커브들 * 스윕 링 사이의 **볼록 사각형** 한 칸 — 풋프린트 교차 판정(`CarveFootprint.outerAt`)
* 이어져 있으면 최외곽만 있으면 된다. * 쓰는 자료형이다.
* *
* 방법(불리언 라이브러리 없이): * 옛 기하 합집합(`unionOutline`)은 2026-08-26 삭제했다 — 겹친 스트립에서 외곽선이
* ① 모든 변을 서로의 교점에서 잘라 토막 낸다. * 무너졌다(같은 토막이 짝수 번 나오면 버리는 규칙이 외곽의 공유 변까지 지웠다).
* ② **다른 사각형 안**으로 들어간 토막을 버린다 — 내부 칸막이가 여기서 사라진다. * 구조물 탑뷰 외곽은 `_Corridor_Plan_Footprint.ts`가 링별 구간 병합으로 낸다.
* ③ 남은 토막을 끝점끼리 이어 닫힌 고리로 만든다.
* 겹치거나 맞닿은 조각들은 하나의 외곽선이 되고, 떨어진 무리는 각각 고리가 된다.
* ========================================================================== */ * ========================================================================== */
/** 실좌표 XY 점. */ /** 실좌표 XY 점. */
@@ -25,12 +23,6 @@ export interface UnionQuad {
maxY: number; maxY: number;
} }
/** 좌표 스냅 눈금(m) — 끝점 이어붙이기·중복 제거 기준. */
const SNAP_M = 1e-3;
/** 겹침 판정 여유 — 이만큼 안쪽이면 "다른 조각 안"으로 본다. */
const INSIDE_EPS_M = 1e-4;
export function unionQuadOf(a: PXY, b: PXY, c: PXY, d: PXY): UnionQuad { export function unionQuadOf(a: PXY, b: PXY, c: PXY, d: PXY): UnionQuad {
return { return {
points: [a, b, c, d], points: [a, b, c, d],
@@ -40,150 +32,3 @@ export function unionQuadOf(a: PXY, b: PXY, c: PXY, d: PXY): UnionQuad {
maxY: Math.max(a.y, b.y, c.y, d.y), maxY: Math.max(a.y, b.y, c.y, d.y),
}; };
} }
function snapKey(point: PXY): string {
return `${Math.round(point.x / SNAP_M)}:${Math.round(point.y / SNAP_M)}`;
}
/** 볼록 사각형 안(경계 제외)인가 — 감김 방향과 무관하게 본다. */
function insideQuad(point: PXY, quad: UnionQuad): boolean {
if (
point.x < quad.minX - INSIDE_EPS_M ||
point.x > quad.maxX + INSIDE_EPS_M ||
point.y < quad.minY - INSIDE_EPS_M ||
point.y > quad.maxY + INSIDE_EPS_M
) {
return false;
}
let positive = 0;
let negative = 0;
for (let i = 0; i < 4; i += 1) {
const a = quad.points[i];
const b = quad.points[(i + 1) % 4];
const ex = b.x - a.x;
const ey = b.y - a.y;
const length = Math.hypot(ex, ey);
if (length < 1e-9) continue;
// 변에서의 부호 있는 거리 — 경계에 붙은 점은 "안"으로 안 센다.
const distance = (ex * (point.y - a.y) - ey * (point.x - a.x)) / length;
if (distance > INSIDE_EPS_M) positive += 1;
else if (distance < -INSIDE_EPS_M) negative += 1;
else return false;
}
return positive === 0 || negative === 0;
}
/** ① 모든 변을 서로의 교점에서 토막 낸다. */
function splitEdges(quads: ReadonlyArray<UnionQuad>): Array<[PXY, PXY]> {
const pieces: Array<[PXY, PXY]> = [];
for (let qi = 0; qi < quads.length; qi += 1) {
const quad = quads[qi];
for (let ei = 0; ei < 4; ei += 1) {
const a = quad.points[ei];
const b = quad.points[(ei + 1) % 4];
const dx = b.x - a.x;
const dy = b.y - a.y;
if (Math.hypot(dx, dy) < SNAP_M) continue;
const cuts = [0, 1];
for (let qj = 0; qj < quads.length; qj += 1) {
if (qj === qi) continue;
const other = quads[qj];
if (
other.minX > Math.max(a.x, b.x) ||
other.maxX < Math.min(a.x, b.x) ||
other.minY > Math.max(a.y, b.y) ||
other.maxY < Math.min(a.y, b.y)
) {
continue;
}
for (let ej = 0; ej < 4; ej += 1) {
const c = other.points[ej];
const d = other.points[(ej + 1) % 4];
const ex = d.x - c.x;
const ey = d.y - c.y;
const denominator = dx * ey - dy * ex;
if (Math.abs(denominator) < 1e-12) continue;
const t = ((c.x - a.x) * ey - (c.y - a.y) * ex) / denominator;
const u = ((c.x - a.x) * dy - (c.y - a.y) * dx) / denominator;
if (t > 1e-9 && t < 1 - 1e-9 && u > -1e-9 && u < 1 + 1e-9) cuts.push(t);
}
}
cuts.sort((x, y) => x - y);
for (let k = 0; k < cuts.length - 1; k += 1) {
const t0 = cuts[k];
const t1 = cuts[k + 1];
if (t1 - t0 < 1e-9) continue;
pieces.push([
{ x: a.x + dx * t0, y: a.y + dy * t0 },
{ x: a.x + dx * t1, y: a.y + dy * t1 },
]);
}
}
}
return pieces;
}
/**
* 사각형 무리의 **합집합 경계**. 반환은 닫힌 고리들(마지막 점 = 첫 점).
*/
export function unionOutline(quads: ReadonlyArray<UnionQuad>): PXY[][] {
if (!quads.length) return [];
// ② 내부 토막을 버린다. 두 가지가 내부다.
// · 다른 사각형 **안**으로 들어간 토막.
// · 두 사각형이 **맞대고 공유하는 변** — 두 번 나온다. 하나만 남기면 칸막이가
// 그대로 살아 격자가 된다(2026-08-25 실측: 루프 133개). 짝수 번 나온 토막은
// 통째로 버리고 **한 번만 나온 토막**(진짜 바깥 변)만 남긴다.
const pieces = splitEdges(quads).filter((piece) => {
const middle = { x: (piece[0].x + piece[1].x) / 2, y: (piece[0].y + piece[1].y) / 2 };
return !quads.some((quad) => insideQuad(middle, quad));
});
const counts = new Map<string, number>();
const keyOf = (piece: [PXY, PXY]): string =>
[snapKey(piece[0]), snapKey(piece[1])].sort().join("|");
for (const piece of pieces) counts.set(keyOf(piece), (counts.get(keyOf(piece)) ?? 0) + 1);
const taken = new Set<string>();
const kept: Array<[PXY, PXY]> = [];
for (const piece of pieces) {
const key = keyOf(piece);
if ((counts.get(key) ?? 0) !== 1 || taken.has(key)) continue;
taken.add(key);
kept.push(piece);
}
if (!kept.length) return [];
// ③ 끝점 인접표를 만들어 고리로 잇는다. 각 토막은 한 번씩만 쓴다.
const links = new Map<string, Array<{ index: number; to: PXY }>>();
const link = (key: string, index: number, to: PXY): void => {
const bucket = links.get(key);
if (bucket) bucket.push({ index, to });
else links.set(key, [{ index, to }]);
};
kept.forEach((piece, index) => {
link(snapKey(piece[0]), index, piece[1]);
link(snapKey(piece[1]), index, piece[0]);
});
const used = new Array<boolean>(kept.length).fill(false);
const loops: PXY[][] = [];
for (let start = 0; start < kept.length; start += 1) {
if (used[start]) continue;
used[start] = true;
const loop: PXY[] = [kept[start][0], kept[start][1]];
let cursor = kept[start][1];
// 토막 수를 넘기면 멈춘다 — 자료가 깨져도 무한 루프에 빠지지 않는다.
for (let guard = 0; guard < kept.length; guard += 1) {
if (snapKey(cursor) === snapKey(loop[0])) break;
const next = (links.get(snapKey(cursor)) ?? []).find((entry) => !used[entry.index]);
if (!next) break;
used[next.index] = true;
loop.push(next.to);
cursor = next.to;
}
if (loop.length < 3) continue;
// 닫아 준다 — 끝이 시작과 다르면 되돌아오는 선 하나를 더한다.
if (snapKey(loop[loop.length - 1]) !== snapKey(loop[0])) loop.push(loop[0]);
loops.push(loop);
}
return loops;
}