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