diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 34a221c5..0239e426 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -84,7 +84,7 @@ function fnv1a(text: string): string { * (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에 * 섞어 두면 배포와 동시에 저장본이 만료된다. */ -const BUILD_VERSION = 31; // 구조물 커브·성토부 커브 분리 산출(2026-08-25). +const BUILD_VERSION = 32; // 구조물 탑뷰 풋프린트 재구현 — 부재별 표기(2026-08-26). /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts index 27826fc7..48b66d7f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts @@ -1,17 +1,17 @@ /* ============================================================================= * B05_Profile_UI_Corridor_Plan_Footprint.ts - * 구조물 세트의 **탑뷰 풋프린트 외곽 고리**(2026-08-26 사용자 재구현 지시). + * 구조물의 **탑뷰 풋프린트 외곽 고리**(2026-08-26 사용자 재구현 지시). * * 기하 합집합(옛 `_Corridor_Union.ts`)을 쓰지 않는다 — 겹친 스트립에서 외곽선이 * 무너졌다(같은 토막이 짝수 번 나오면 버리는 규칙이 외곽의 공유 변까지 지웠다). * * 대신 구조물이 **스윕 솔리드**라는 성질을 그대로 쓴다: - * ① 링 경로(프레임 중심 열)가 같은 부재끼리 묶는다 — 같은 자리를 지나는 부재는 - * 링 번호가 그대로 맞아떨어진다. 날개벽처럼 제 갈 길 가는 부재는 따로 남는다. - * ② 묶음 안에서 **링마다** 부재들의 편거리 창을 도로 밖으로 자르고 1차원 병합. - * ③ 링을 따라 겹치는 구간끼리 이어 레인을 만들고, 레인마다 닫힌 고리 하나. + * ① 부재마다 **따로** 낸다 — 겹친 부재를 하나로 머지하지 않는다 + * (2026-08-26 사용자: "구조물은 머지하지 말고 표현할 것, 나중에 한번에"). + * ② 링마다 그 링 폴리곤이 차지하는 편거리 창을 도로 밖으로 자른다. + * ③ 창이 있는 링이 이어지는 동안 한 고리 — 창이 없는 링에서 끊는다. * - * 1차원 구간 병합이라 퇴화(같은 사각형 겹침·포함 관계·맞닿은 변)가 없다. + * 기하 불리언이 없어 퇴화(같은 사각형 겹침·포함 관계·맞닿은 변)가 없다. * ========================================================================== */ import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures"; @@ -19,12 +19,9 @@ import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corrido /** 안팎 폭이 이만큼은 돼야 커브를 만든다(m). */ const MIN_WIDTH_M = 0.05; -/** 구간 병합·겹침 판정 여유(m). */ +/** 폭 판정 여유(m). */ const EPS_M = 1e-6; -/** 링 경로 묶음 키의 좌표 눈금(m). */ -const PATH_SNAP_M = 1e-3; - type Side = "left" | "right"; /** 편거리 구간 — 항상 `lo <= hi`(부호 있는 편거리 그대로). */ @@ -49,88 +46,33 @@ function clipOutsideRoad(span: Span, side: Side, roadEdgeOffset: number): Span | 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 Row { + frame: StructureFrame; + span: Span; } -/** 링 하나에서 이어지는 스트립 한 줄 — 레인. */ -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; +/** 이어진 링 줄을 닫힌 고리로 — 바깥 가장자리를 앞으로, 안쪽을 뒤로 이어 닫는다. */ +function runLoop( + run: ReadonlyArray, + side: Side, + planZ: number, +): Array<[number, number, number]> | null { + if (run.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) { + for (const row of run) { 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)); + for (let i = run.length - 1; i >= 0; i -= 1) { + const [x, y] = at(run[i].frame, innerOf(run[i].span)); loop.push([x, y, planZ]); } loop.push(loop[0]); @@ -138,9 +80,9 @@ function laneLoop(lane: Lane, side: Side, planZ: number): Array<[number, number, } /** - * 구조물 세트의 그 측 탑뷰 외곽 고리들 — 도로부(노견 안쪽)는 뺀다. + * 구조물 세트의 그 측 탑뷰 외곽 고리들 — **부재마다 따로**, 도로부(노견 안쪽)는 뺀다. * - * 배관은 뺀다 — 다른 부재 속을 지나는 부재라 링 열이 없다. + * 배관은 뺀다 — 다른 부재가 감싸고 있어 투영 면적에 안 들어간다(2026-08-26 사용자). */ export function structureFootprintLoops( set: ReadonlyArray, @@ -148,35 +90,25 @@ export function structureFootprintLoops( roadEdgeOffset: number, planZ: number, ): Array> { - const groups = new Map(); + const loops: Array> = []; 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); + let run: Row[] = []; + const flush = (): void => { + const loop = runLoop(run, side, planZ); if (loop) loops.push(loop); - } + run = []; + }; + rings.forEach((frame, index) => { + const polygon = structure.polygons?.[index] ?? structure.polygon; + const span = polygonSpan(polygon); + const clipped = span ? clipOutsideRoad(span, side, roadEdgeOffset) : null; + if (clipped) run.push({ frame, span: clipped }); + else flush(); + }); + flush(); } return loops; }