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