fix(B05): 측벽을 본 구조물·날개 **머지 영역의 경계**로만 세운다

절취 영역은 본 구조물 스트립과 날개 사다리꼴을 **합친 것**인데, 벽은 두 스케치의
변을 따로 세우고 있었다. 그래서 한쪽 영역의 변이 다른 영역 **안쪽**을 지나는 구간
(속살)에도 벽이 섰다 — 세월교 좌측 실측: 날개 광선 1.87m·1.77m 구간이 스트립 안에
통째로 잠겨 있었다(2026-08-27 사용자 지적).

직전 커밋(86d1d6ac)은 날개가 있는 측의 끝 횡단선을 통째로 뺐는데, 그러면 날개가
못 덮는 도로 쪽 구간이 비어 반대로 모자랐다. 되돌리고 **경계 트림**으로 바꾼다.

- Plan: `trimToUnionBoundary()` 신설 — 벽 후보 변을 다른 영역 테두리와의 교점에서
  쪼개고, 조각 가운데가 다른 영역 안이면 버린다. 2cm 미만 부스러기도 버린다.
  세트마다 벽 후보(스트립 끝 횡단선 2개 + 날개 [끝선→날개→끝선])와 근거 영역을
  모아 두었다가 세트가 끝날 때 한 번에 `cut-wall`로 낸다.
- BUILD_VERSION 59 → 60.

검증(실빌드 데이터, 구간 중점 양옆 2cm 판정):
- 8개 세트·측 **전부 경계 100%** — 속살(양옆 다 안쪽) 0, 영역 밖 0.
- 구간 수 세트당 2~8(84.3 2 · 149.73 6 · 200.92 8 · 275.71 2), 벽 판 16장/측,
  삼각형 32개/측, 판 길이 0.17~11.52m, 최대 높이 3.15m.
- 노견선·성토 끝선 위 판 0 유지.
- ISO 근접 캡처: BOX·세월교 절취부 옆면이 큰 판으로 이어지고 구멍 없음.
- npm run typecheck 통과, prettier 적용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 12:55:49 +09:00
co-authored by Claude Opus 5
parent 86d1d6ac13
commit 106536b145
2 changed files with 105 additions and 12 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ function fnv1a(text: string): string {
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게
// 아니라 리본이 끝난 자리다(2026-08-27 사용자).
const BUILD_VERSION = 59;
const BUILD_VERSION = 60;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+104 -11
View File
@@ -306,6 +306,77 @@ const VERTEX_SNAP_M = 0.001;
/** 투영선을 서피스에서 띄우는 높이(m) — z-fighting 방지. */
const PROJECT_LIFT_M = 0.02;
/** XY 다각형 안팎 판정(광선 투사). */
function insideRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
let hit = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
}
return hit;
}
/**
* 벽 자리를 **머지 영역의 경계**만 남긴다(2026-08-27 사용자).
*
* 절취 영역은 본 구조물 스트립과 날개 사다리꼴을 **합친 것**이라, 한쪽 영역의 변이
* 다른 영역 **안쪽**을 지나면 그건 경계가 아니라 속살이다. 변을 다른 영역 테두리와의
* 교점에서 쪼갠 뒤, 조각 가운데가 다른 영역 안이면 버린다.
*/
function trimToUnionBoundary(
parts: ReadonlyArray<{ points: ReadonlyArray<[number, number, number]>; owner: number }>,
areas: ReadonlyArray<ReadonlyArray<[number, number]>>,
): Array<Array<[number, number, number]>> {
const lines: Array<Array<[number, number, number]>> = [];
for (const part of parts) {
for (let i = 0; i < part.points.length - 1; i += 1) {
const from = part.points[i];
const to = part.points[i + 1];
const rx = to[0] - from[0];
const ry = to[1] - from[1];
if (Math.hypot(rx, ry) < 1e-9) continue;
const cuts = [0, 1];
areas.forEach((ring, index) => {
if (index === part.owner) return;
for (let k = 0, j = ring.length - 1; k < ring.length; j = k, k += 1) {
const sx = ring[k][0] - ring[j][0];
const sy = ring[k][1] - ring[j][1];
const denominator = rx * sy - ry * sx;
if (Math.abs(denominator) < 1e-12) continue;
const dx = ring[j][0] - from[0];
const dy = ring[j][1] - from[1];
const t = (dx * sy - dy * sx) / denominator;
const u = (dx * ry - dy * rx) / denominator;
if (t > 1e-9 && t < 1 - 1e-9 && u >= 0 && u <= 1) cuts.push(t);
}
});
cuts.sort((a, b) => a - b);
const length = Math.hypot(rx, ry);
for (let c = 0; c < cuts.length - 1; c += 1) {
const t0 = cuts[c];
const t1 = cuts[c + 1];
// 2cm 미만 조각은 버린다 — 교점이 겹쳐 생기는 부스러기다.
if ((t1 - t0) * length < 0.02) continue;
const mid = (t0 + t1) / 2;
const mx = from[0] + rx * mid;
const my = from[1] + ry * mid;
const buried = areas.some(
(ring, index) => index !== part.owner && insideRing(ring, mx, my),
);
if (buried) continue;
const at = (t: number): [number, number, number] => [
from[0] + rx * t,
from[1] + ry * t,
from[2] + (to[2] - from[2]) * t,
];
lines.push([at(t0), at(t1)]);
}
}
}
return lines;
}
/** 스트립 행을 **점마다 다른 표고**로 얹어 닫힌 고리로 만든다(투영선용). */
function projectedLoops(
rows: ReadonlyArray<[[number, number, number], [number, number, number]] | null>,
@@ -387,8 +458,23 @@ export function buildPlanCurves(
leftX: section.frame.left_xy[0],
leftY: section.frame.left_xy[1],
};
/** 날개벽 벽면을 낸 측 — 그 측 스트립 끝 횡단선은 겹치므로 안 낸다(2026-08-27 사용자). */
const wingWallSides = new Set<Side>();
// 벽 자리 후보와 그 근거 영역 — 세트가 끝나면 **머지 경계**만 남겨 한 번에 낸다.
const wallParts = new Map<
Side,
Array<{ points: Array<[number, number, number]>; owner: number }>
>();
const wallAreas = new Map<Side, Array<Array<[number, number]>>>();
const addArea = (side: Side, loop: ReadonlyArray<[number, number, number]>): number => {
const bucket = wallAreas.get(side) ?? [];
bucket.push(loop.map((point) => [point[0], point[1]] as [number, number]));
wallAreas.set(side, bucket);
return bucket.length - 1;
};
const addWall = (side: Side, points: Array<[number, number, number]>, owner: number): void => {
const bucket = wallParts.get(side) ?? [];
bucket.push({ points, owner });
wallParts.set(side, bucket);
};
(["left", "right"] as const).forEach((side) => {
push("structure", side, structureFootprintLoops(set, side, roadEdgeOf(side), planZ, axis));
// 날개벽만 — 성토 끝선까지 이어진 사다리꼴(2026-08-26 사용자). 성토 끝선은
@@ -398,8 +484,11 @@ export function buildPlanCurves(
const wing = wingBoxLoops(set, side, planZ, axis, toe);
push("wing-box", side, wing.loops);
// 날개 사다리꼴의 벽 자리 — 성토 끝선 변은 빠져 있다(2026-08-27 사용자).
push("cut-wall", side, wing.walls);
if (wing.walls.length) wingWallSides.add(side);
wing.loops.forEach((loop, index) => {
const owner = addArea(side, loop);
const line = wing.walls[index];
if (line) addWall(side, line, owner);
});
});
for (const silhouette of structureSilhouettes(section)) {
@@ -507,20 +596,24 @@ export function buildPlanCurves(
);
});
push("slope-original", side, stripLoops(rows, planZ));
push("slope-projected", side, projectedLoops(projected));
const stripLoopsXY = projectedLoops(projected);
push("slope-projected", side, stripLoopsXY);
// 측벽이 설 자리 = 스트립의 **양 끝 횡단선**뿐. 긴 변(노견선·성토 끝선)은 뺀다
// (거기서 잘린 게 아니라 리본이 끝난 자리다 — 2026-08-27 사용자).
const spans = projected.filter(
(pair): pair is [[number, number, number], [number, number, number]] => pair !== null,
);
// 날개벽이 있는 측은 그 벽면이 같은 자리를 이미 막는다 — 끝 횡단선은 중복이다.
if (spans.length >= 2 && !wingWallSides.has(side)) {
push("cut-wall", side, [
[spans[0][0], spans[0][1]],
[spans[spans.length - 1][0], spans[spans.length - 1][1]],
]);
if (spans.length >= 2 && stripLoopsXY.length) {
const owner = addArea(side, stripLoopsXY[0]);
addWall(side, [spans[0][0], spans[0][1]], owner);
addWall(side, [spans[spans.length - 1][0], spans[spans.length - 1][1]], owner);
}
}
// 본 구조물 영역 ∪ 날개 영역의 **경계만** 벽으로 낸다 — 다른 영역 안에 잠긴
// 구간은 속살이라 버린다(2026-08-27 사용자).
wallParts.forEach((parts, side) => {
push("cut-wall", side, trimToUnionBoundary(parts, wallAreas.get(side) ?? []));
});
}
return curves;
}