fix(B05): 측벽을 격자 계단이 아니라 실제로 잘린 선에 세운다

사용자 지적: 벽이 최종 절단면이 아니라 셀 격자를 따라간다. 실측으로 확인 —
벽 정점이 리본 격자점과 100% 일치하고, 실제 절단커브까지 거리 중앙값 5.6~5.9cm
최대 21~30cm(셀 대각선 크기)였다. 격자는 재배치 후(0.2m) 격자가 맞았고, 문제는
`pushSkirt`가 마스크 격자 윤곽을 그대로 밀어낸 것이었다.

- `clipCellByChord`가 남길 조각과 함께 **교점 직선**(chord)을 돌려준다.
- `SkirtBuilder` 신설 — 걸친 셀은 그 셀을 자른 교점 직선에, 통째로 지운 셀은
  **안 지워진 이웃 쪽 모서리**에만 벽을 세운다(걸친 셀은 조각이 모서리까지 덮으므로
  모서리 벽을 세우지 않는다). 밑선은 원지반 − 1m, 교점 위 점은 원지반을 이중선형 보간.
- `traceMaskLoops` 기반 스커트 제거(그 함수는 `cut-merged` 표시 커브에만 남는다).
- BUILD_VERSION 54 → 55.

검증(실빌드 데이터):
- 벽 ↔ 절단커브 거리: 중앙값 0.000 · p90 0.000 · 평균 0.001/0.000 · 최대 0.091/0.010m
  (종전 중앙값 0.056/0.059 · 최대 0.213/0.298).
- 격자점과 일치 100% → 71.6/71.8%(나머지는 교점 위 점 = 격자 밖).
- 벽 삼각형 360/356 → 1,450/1,358. 리본 조각 삼각형 814/787 불변.
- ISO 화면에서 절취부 테두리 계단이 사라졌다.
- npm run typecheck 통과, prettier 적용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:08:19 +09:00
co-authored by Claude Opus 5
parent e9914af675
commit d70908f973
2 changed files with 113 additions and 38 deletions
+3 -3
View File
@@ -106,9 +106,9 @@ function fnv1a(text: string): string {
// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26).
// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
// 54 = 절취 측벽을 리본 메시에서 떼어 **별도 서피스**로 내고, 원지반 아래로 여유
// 1m를 더 내린다(2026-08-27 사용자).
const BUILD_VERSION = 54;
// 55 = 측벽을 **실제로 잘린 선**(걸친 셀은 교점 직선, 통째 셀은 이웃 모서리)에
// 세운다 — 종전 격자 계단은 면 경계와 최대 0.3m 어긋났다(2026-08-27 사용자).
const BUILD_VERSION = 55;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+110 -35
View File
@@ -116,6 +116,12 @@ function ringCrossings(areas: ReadonlyArray<CurveArea>, ring: ReadonlyArray<Corn
return hits;
}
/** 셀 클리핑 결과 — 남길 바깥 조각과, 실제로 잘린 **교점 직선**(측벽이 설 자리). */
interface CellClip {
kept: Corner[];
chord: [Corner, Corner];
}
/**
* 셀을 교점 두 개가 만드는 **직선 하나로** 자르고, 커브 **바깥** 쪽 조각을 돌려준다.
* 교점이 2개가 아니면 null — 부르는 쪽이 셀을 통째로 남긴다.
@@ -123,7 +129,7 @@ function ringCrossings(areas: ReadonlyArray<CurveArea>, ring: ReadonlyArray<Corn
function clipCellByChord(
areas: ReadonlyArray<CurveArea>,
ring: ReadonlyArray<Corner>,
): Corner[] | null {
): CellClip | null {
const hits = ringCrossings(areas, ring);
// 교점은 보통 2개다. 귀퉁이를 스치거나 커브 마디가 셀 안에서 꺾이면 더 나오는데,
// 그때도 **처음과 마지막**을 이으면 셀 안 경계와 거의 같다(셀이 0.2m × 6cm라
@@ -169,7 +175,7 @@ function clipCellByChord(
});
}
}
return kept.length >= 3 ? kept : null;
return kept.length >= 3 ? { kept, chord: [h0, h1] } : null;
}
/**
@@ -281,38 +287,84 @@ function dropCollinear(loop: ReadonlyArray<[number, number]>): Array<[number, nu
const SKIRT_MARGIN_M = 1;
/**
* 잘린 자리 **측벽**(2026-08-27 사용자) — 절취 윤곽을 z로 밀어내 성토면에서 원지반
* 잘린 자리 **측벽**(2026-08-27 사용자) — 절취 경계를 z로 밀어내 성토면에서 원지반
* 아래(`SKIRT_MARGIN_M`)까지 세운다. 리본 메시에 섞지 않고 **구조물처럼 별도 서피스**로
* 낸다 — UV·법선이 리본에 묶이지 않아 다음 단계(바닥면·트림)에서 따로 다룰 수 있다.
*
* ponytail: 벽은 격자 모서리를 타는 윤곽(계단 6cm × 0.2m)을 그대로 쓴다 — 걸친 셀 안에서
* 실제 절단선은 교점 직선이라 최대 한 셀만큼 어긋난다. 화면에서 틈이 보이면 그때
* 교점 직선으로 세우면 된다.
* 벽이 서는 자리는 **실제로 잘린 선**이다(2026-08-27 사용자 지적으로 정정):
* · 걸친 셀 → 그 셀을 자른 **교점 직선**(면 조각의 경계와 같은 선).
* · 통째로 지운 셀 → 이웃이 **안 지워진 셀**인 쪽의 셀 모서리.
* 종전에는 마스크 격자 윤곽(셀 계단)을 그대로 밀어내 면 경계와 최대 0.3m 어긋났다.
*/
function pushSkirt(
loop: ReadonlyArray<[number, number]>,
ribbon: CorridorRibbon,
tris: number[],
): void {
const ground = ribbon.groundZ;
if (!ground) return;
const cols = ribbon.colCount;
/** 격자점 → [x, y, 서피스 z, 지반 z 여유]. */
const at = (row: number, col: number): [number, number, number, number] => {
const i = (row * cols + col) * 3;
return [
ribbon.positions[i],
ribbon.positions[i + 1],
ribbon.positions[i + 2],
ground[row * cols + col] - SKIRT_MARGIN_M,
];
};
for (let i = 0; i < loop.length - 1; i += 1) {
const [x1, y1, top1, bottom1] = at(loop[i][0], loop[i][1]);
const [x2, y2, top2, bottom2] = at(loop[i + 1][0], loop[i + 1][1]);
if (top1 - bottom1 < 1e-6 && top2 - bottom2 < 1e-6) continue; // 높이 0이면 벽이 없다.
tris.push(x1, y1, top1, x2, y2, top2, x2, y2, bottom2);
tris.push(x1, y1, top1, x2, y2, bottom2, x1, y1, bottom1);
class SkirtBuilder {
private readonly tris: number[] = [];
constructor(
private readonly ribbon: CorridorRibbon,
private readonly ground: Float64Array,
) {}
/** 셀 안 상대좌표(a: 열, b: 행)로 원지반 표고를 이중선형 보간한다. */
private groundAt(row: number, col: number, corner: Corner): number {
const cols = this.ribbon.colCount;
const g = (r: number, c: number): number => this.ground[r * cols + c];
const a = Math.min(1, Math.max(0, corner.a - col));
const b = Math.min(1, Math.max(0, corner.b));
const top = g(row, col) + (g(row, col + 1) - g(row, col)) * a;
const bottom = g(row + 1, col) + (g(row + 1, col + 1) - g(row + 1, col)) * a;
return top + (bottom - top) * b - SKIRT_MARGIN_M;
}
private quad(
x1: number,
y1: number,
top1: number,
bottom1: number,
x2: number,
y2: number,
top2: number,
bottom2: number,
): void {
if (top1 - bottom1 < 1e-6 && top2 - bottom2 < 1e-6) return; // 높이 0이면 벽이 없다.
if (Math.hypot(x2 - x1, y2 - y1) < 1e-9) return;
this.tris.push(x1, y1, top1, x2, y2, top2, x2, y2, bottom2);
this.tris.push(x1, y1, top1, x2, y2, bottom2, x1, y1, bottom1);
}
/** 걸친 셀 — 그 셀을 자른 교점 직선에 벽을 세운다. */
addChord(row: number, col: number, chord: readonly [Corner, Corner]): void {
const [h0, h1] = chord;
this.quad(
h0.p[0],
h0.p[1],
h0.p[2],
this.groundAt(row, col, h0),
h1.p[0],
h1.p[1],
h1.p[2],
this.groundAt(row, col, h1),
);
}
/** 통째로 지운 셀 — 안 지워진 이웃 쪽 셀 모서리에 벽을 세운다. */
addEdge(rowA: number, colA: number, rowB: number, colB: number): void {
const cols = this.ribbon.colCount;
const at = (row: number, col: number): [number, number, number, number] => {
const i = (row * cols + col) * 3;
return [
this.ribbon.positions[i],
this.ribbon.positions[i + 1],
this.ribbon.positions[i + 2],
this.ground[row * cols + col] - SKIRT_MARGIN_M,
];
};
const [x1, y1, top1, bottom1] = at(rowA, colA);
const [x2, y2, top2, bottom2] = at(rowB, colB);
this.quad(x1, y1, top1, bottom1, x2, y2, top2, bottom2);
}
result(): Float64Array | null {
return this.tris.length ? new Float64Array(this.tris) : null;
}
}
@@ -352,11 +404,13 @@ export function maskFillByPlanCurves(
const cols = ribbon.colCount;
if (rows < 2 || cols < 2) return ribbon;
const mask = new Uint8Array((rows - 1) * (cols - 1));
/** 교점 직선으로 잘린 셀 — 그 셀의 벽은 이미 섰으니 모서리 벽을 또 세우지 않는다. */
const chorded = new Uint8Array((rows - 1) * (cols - 1));
let masked = 0;
const tris: number[] = [];
const uvs: number[] = [];
// 측벽은 리본 메시가 아니라 **별도 서피스**로 나간다(2026-08-27 사용자).
const wallTris: number[] = [];
const skirt = ribbon.groundZ ? new SkirtBuilder(ribbon, ribbon.groundZ) : null;
const startChainage = ribbon.chainages[0];
const point = (row: number, col: number): [number, number, number] => {
const i = (row * cols + col) * 3;
@@ -385,11 +439,11 @@ export function maskFillByPlanCurves(
masked += 1;
continue;
}
const kept = clipCellByChord(areas, ring);
const clip = clipCellByChord(areas, ring);
// 절단 실패는 **안쪽 면적 비율**로 가른다(2026-08-27 사용자: 면적 필터).
// 절반 이상 잠긴 셀은 지운다 — 종전처럼 통째로 남기면 경계에 미세 메쉬가
// 줄줄이 남았다(실측: 좌 313·우 314셀, 4.50·2.17㎡).
if (!kept) {
if (!clip) {
if (coverage(areas, ring) >= 0.5) {
mask[row * (cols - 1) + col] = 1;
masked += 1;
@@ -397,7 +451,10 @@ export function maskFillByPlanCurves(
continue;
}
mask[row * (cols - 1) + col] = 1;
chorded[row * (cols - 1) + col] = 1;
masked += 1;
skirt?.addChord(row, col, clip.chord);
const kept = clip.kept;
for (let k = 1; k < kept.length - 1; k += 1) {
for (const corner of [kept[0], kept[k], kept[k + 1]]) {
tris.push(corner.p[0], corner.p[1], corner.p[2]);
@@ -408,6 +465,24 @@ export function maskFillByPlanCurves(
}
}
if (!masked) return ribbon;
// 통째로 지운 셀은 **안 지워진 이웃** 쪽 모서리에만 벽을 세운다. 걸친 셀은 위에서
// 교점 직선으로 이미 세웠고, 그 셀의 남은 조각이 모서리까지 덮으므로 건드리지 않는다.
if (skirt) {
const filled = (row: number, col: number): boolean =>
row >= 0 && col >= 0 && row < rows - 1 && col < cols - 1
? mask[row * (cols - 1) + col] === 0
: true;
for (let row = 0; row < rows - 1; row += 1) {
for (let col = 0; col < cols - 1; col += 1) {
const i = row * (cols - 1) + col;
if (!mask[i] || chorded[i]) continue;
if (filled(row - 1, col)) skirt.addEdge(row, col, row, col + 1);
if (filled(row + 1, col)) skirt.addEdge(row + 1, col + 1, row + 1, col);
if (filled(row, col - 1)) skirt.addEdge(row + 1, col, row, col);
if (filled(row, col + 1)) skirt.addEdge(row, col + 1, row + 1, col + 1);
}
}
}
// 잘린 자리 윤곽을 **새 데이텀**에 얹어 따로 낸다 — 어디가 잘리는지 눈으로 본다.
// 성토 리본은 좌·우뿐이라 커브의 측 표기와 그대로 맞는다.
const side: PlanCurve["side"] = ribbon.side === "right" ? "right" : "left";
@@ -415,7 +490,6 @@ export function maskFillByPlanCurves(
traceMaskLoops(rows, cols, mask).forEach((raw) => {
const loop = dropCollinear(raw);
if (loop.length < 4 || !planes.length) return;
pushSkirt(loop, ribbon, wallTris);
const meanRow = loop.reduce((sum, [row]) => sum + row, 0) / loop.length;
const chainage = ribbon.chainages[Math.min(rows - 1, Math.round(meanRow))];
const plane = planes.reduce((best, item) =>
@@ -436,7 +510,8 @@ export function maskFillByPlanCurves(
],
});
});
if (wallTris.length) walls.push({ side, positions: new Float64Array(wallTris) });
const wall = skirt?.result();
if (wall) walls.push({ side, positions: wall });
return {
...ribbon,
cellMask: mask,