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:
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user