diff --git a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts index 2e9ebda1..6d852007 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts @@ -33,7 +33,7 @@ import { } from "./B05_Profile_UI_Corridor_Station"; import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; 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"; /** 풋프린트가 노견보다 이만큼은 밖으로 나가야 자를 값어치가 있다(m). */ @@ -81,18 +81,6 @@ export class CarveFootprint { 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)이 풋프린트와 * 겹치는 **그 측의 가장 바깥 편거리**. 안 겹치면 null. diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts index af223076..0acfcc2c 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts @@ -18,7 +18,8 @@ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import type { CorridorStructure, RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; 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 { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; 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 }; } -/** 그 측에서 노견 바깥에 남는 편거리 구간 — 도로부를 잘라낸 뒤 폭이 없으면 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)을 더한 값의 최대. */ function topElevationOf(set: ReadonlyArray): number | null { let top: number | null = null; @@ -124,40 +113,6 @@ function topElevationOf(set: ReadonlyArray): number | null { return top; } -/** 세트 구조물 솔리드의 그 측 탑뷰 외곽 — 도로부를 뺀 링 사이 사각형의 합집합. */ -function structureLoops( - set: ReadonlyArray, - side: Side, - roadEdgeOffset: number, - planZ: number, -): Array> { - 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)을 평면 표고에 얹어 닫힌 고리로 만든다. */ function stripLoops( rows: ReadonlyArray<[PXY, PXY] | null>, @@ -233,7 +188,7 @@ export function buildPlanCurves( }; (["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)) { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts new file mode 100644 index 00000000..27826fc7 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts @@ -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 | 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[] { + 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): 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, + spansPerRing: ReadonlyArray, +): Lane[] { + const lanes: Lane[] = []; + let open: Lane[] = []; + rings.forEach((frame, index) => { + const spans = spansPerRing[index]; + const carried: Lane[] = []; + const claimed = new Set(); + 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, + side: Side, + roadEdgeOffset: number, + planZ: number, +): Array> { + const groups = new Map(); + 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> = []; + 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; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Union.ts b/B05_Profile/B05_Profile_UI_Corridor_Union.ts index c7ad854c..52720936 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Union.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Union.ts @@ -1,13 +1,11 @@ /* ============================================================================= * B05_Profile_UI_Corridor_Union.ts - * 볼록 사각형 무리의 **합집합 경계**(최외곽 윤곽) — 2026-08-25 사용자: 커브들이 - * 이어져 있으면 최외곽만 있으면 된다. + * 스윕 링 사이의 **볼록 사각형** 한 칸 — 풋프린트 교차 판정(`CarveFootprint.outerAt`)이 + * 쓰는 자료형이다. * - * 방법(불리언 라이브러리 없이): - * ① 모든 변을 서로의 교점에서 잘라 토막 낸다. - * ② **다른 사각형 안**으로 들어간 토막을 버린다 — 내부 칸막이가 여기서 사라진다. - * ③ 남은 토막을 끝점끼리 이어 닫힌 고리로 만든다. - * 겹치거나 맞닿은 조각들은 하나의 외곽선이 되고, 떨어진 무리는 각각 고리가 된다. + * 옛 기하 합집합(`unionOutline`)은 2026-08-26 삭제했다 — 겹친 스트립에서 외곽선이 + * 무너졌다(같은 토막이 짝수 번 나오면 버리는 규칙이 외곽의 공유 변까지 지웠다). + * 구조물 탑뷰 외곽은 `_Corridor_Plan_Footprint.ts`가 링별 구간 병합으로 낸다. * ========================================================================== */ /** 실좌표 XY 점. */ @@ -25,12 +23,6 @@ export interface UnionQuad { 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 { return { 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), }; } - -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): 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): 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(); - 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(); - 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>(); - 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(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; -}