fix(B05): 구조물 투영 커브를 부재별로 표기 · 저장본 판번호 상향

화면이 안 바뀐 원인은 저장본 캐시였다. `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) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 17:25:41 +09:00
co-authored by Claude Opus 5
parent b29113ba22
commit 5c8986d46c
2 changed files with 38 additions and 106 deletions
+1 -1
View File
@@ -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 {
@@ -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>): 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 Row {
frame: StructureFrame;
span: Span;
}
/** 링 하나에서 이어지는 스트립 한 줄 — 레인. */
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;
/** 이어진 링 줄을 닫힌 고리로 — 바깥 가장자리를 앞으로, 안쪽을 뒤로 이어 닫는다. */
function runLoop(
run: ReadonlyArray<Row>,
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<CorridorStructure>,
@@ -148,35 +90,25 @@ export function structureFootprintLoops(
roadEdgeOffset: number,
planZ: number,
): Array<Array<[number, number, number]>> {
const groups = new Map<string, CorridorStructure[]>();
const loops: Array<Array<[number, number, number]>> = [];
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);
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;
}