From 5c8986d46c7d1e4fe0a320852b21e0d4783555c6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 26 Aug 2026 17:25:41 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=ED=88=AC=EC=98=81=20=EC=BB=A4=EB=B8=8C=EB=A5=BC=20=EB=B6=80?= =?UTF-8?q?=EC=9E=AC=EB=B3=84=EB=A1=9C=20=ED=91=9C=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=B3=B8=20=ED=8C=90=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=83=81=ED=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면이 안 바뀐 원인은 저장본 캐시였다. `BUILD_VERSION`을 안 올려 코리도 해시가 그대로였고 서버 저장본(planCurves 20개)이 재사용됐다. 실측으로 확인 — 저장본 hash 95cafe53 vs 새 코드 hash 0e67c470. 31 → 32로 올려 만료시킨다. 그리고 겹친 부재를 하나로 머지하지 않는다(사용자: "구조물은 머지하지 말고 표현할 것, 나중에 한번에 진행 예정"). 링 경로 그룹핑·구간 병합을 빼고 부재마다 고리 하나를 낸다. 배관은 투영 면적에서 계속 뺀다 — 다른 부재가 감싸고 있는 구조라는 사용자 확정. 실측(링 횡단선 되투영, 이탈 최대 0.0000m): 84.3 고리 4개 — 집수정 상·하단 2.000~2.650 각 31점, 기슭막이 상단 −2.946~−2.000·하단 −3.250~−2.000 각 47점. 149.73 8개, 200.92 10개(날개벽 4장 각각), 275.71 4개. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor.ts | 2 +- .../B05_Profile_UI_Corridor_Plan_Footprint.ts | 142 +++++------------- 2 files changed, 38 insertions(+), 106 deletions(-) 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; }