feat(B05): 절취 윤곽을 z로 밀어내 측벽을 세우고 성토면에 머지한다

자를 때 쓴 평면 스케치(절취 윤곽)를 z축으로 밀어내 성토면 ↔ 원지반을 잇는 측벽을
세운다(2026-08-27 사용자). 벽은 성토 리본과 **같은 메쉬·같은 재질**에 담아 별도
서피스가 아니라 머지된 한 면으로 그려진다. 구조물이 안착된 성토부 방향(바닥)
서피스는 다음 작업.

- Build: 성토 리본에 격자점별 원지반 표고 `groundZ`를 싣는다(행 지반선을 rows에
  보관해 flush에서 offset으로 읽는다). 벽의 밑선이다.
- Cut: `pushSkirt()` — 절취 윤곽 모서리마다 [서피스 z → 원지반 z] 사각형을 삼각형
  둘로 세워 `trimTris`에 함께 담는다. UV는 u=누가거리/2, v=z/2로 빗금이 세로로도
  실척으로 이어진다. 높이 0인 구간(성토 끝선)은 건너뛴다.
- Corridor: BUILD_VERSION 52 → 53.

검증(실빌드 데이터):
- 벽 삼각형 우 417·좌 418(조각 삼각형 합계 1,166·1,135), groundZ 적재 확인.
- 벽 높이 우 0.001~2.80m(평균 1.08)·좌 0.003~5.25m(평균 1.61).
- ISO 화면에서 세월교·BOX암거 절취부 옆면이 청록 벽으로 막혔다. 바닥은 아직 열림.
- npm run typecheck 통과, prettier 적용.

남은 것: 벽이 격자 모서리를 타 6cm×0.2m 계단이 보인다(교점 직선으로 세우면 없어진다).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 10:35:31 +09:00
co-authored by Claude Opus 5
parent 2daaf127b8
commit 86a20cccb4
3 changed files with 67 additions and 4 deletions
+3 -3
View File
@@ -99,9 +99,9 @@ function fnv1a(text: string): string {
// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26).
// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
// 52 = 날개벽 사다리꼴을 절취 영역에 합치고, 잘린 자리 윤곽을 새 데이텀(+3m)에
// `cut-merged` 커브로 낸다(2026-08-27).
const BUILD_VERSION = 52;
// 53 = 잘린 자리 측벽 — 절취 윤곽을 z로 밀어내 성토면↔원지반을 잇는 벽을 세우고
// 성토 메쉬에 합친다(2026-08-27).
const BUILD_VERSION = 53;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+17 -1
View File
@@ -83,6 +83,11 @@ export interface CorridorRibbon {
trimTris?: Float64Array;
/** `trimTris`와 짝인 UV(삼각형당 6) — 빗금이 실척으로 이어지게 한다. */
trimUvs?: Float32Array;
/**
* 격자점별 **원지반 표고**(rowCount × colCount) — 성토 리본에만 싣는다.
* 잘린 자리 측벽을 성토면에서 원지반까지 세우는 밑선이다(2026-08-27 사용자).
*/
groundZ?: Float64Array;
}
/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */
@@ -242,6 +247,8 @@ export function buildCorridor(
sections: Map<string, OffsetPoint[]>;
outerLeft: OffsetPoint;
outerRight: OffsetPoint;
/** 그 행 지반선 — 성토 리본에 원지반 표고를 함께 실어 절단면 측벽의 밑선으로 쓴다. */
ground: ((offset: number) => number) | null;
}> = [];
const toModel = (row: (typeof rows)[number], point: OffsetPoint): [number, number, number] => [
@@ -537,6 +544,7 @@ export function buildCorridor(
sections,
outerLeft: outerOf("left"),
outerRight: outerOf("right"),
ground: groundRow,
});
}
}
@@ -574,7 +582,10 @@ export function buildCorridor(
}
const chainages: number[] = [];
const positions = new Float64Array((endExclusive - start) * colCount * 3);
// 성토 리본만 원지반 표고를 같이 싣는다 — 잘린 자리 측벽의 밑선(2026-08-27 사용자).
const groundZ = kind === "fill" ? new Float64Array((endExclusive - start) * colCount) : null;
let cursor = 0;
let groundCursor = 0;
for (let row = start; row < endExclusive; row += 1) {
chainages.push(rows[row].chainage_m);
rows[row].sections.get(key)!.forEach((point) => {
@@ -583,9 +594,14 @@ export function buildCorridor(
positions[cursor + 1] = y;
positions[cursor + 2] = z;
cursor += 3;
if (groundZ) {
// 지반선을 못 읽는 행은 서피스 표고를 그대로 둔다 — 높이 0짜리 벽이 된다.
groundZ[groundCursor] = rows[row].ground?.(point.offset_m) ?? z;
groundCursor += 1;
}
});
}
ribbons.push({ kind, side, colCount, chainages, positions });
ribbons.push({ kind, side, colCount, chainages, positions, ...(groundZ ? { groundZ } : {}) });
// 측구 리본이 노선 중간에서 시작·종료하면 그 자리를 벽으로 막는다.
if (kind === "ditch") {
if (start > 0) pushDitchCap(rows[start], key, side);
@@ -276,6 +276,52 @@ function dropCollinear(loop: ReadonlyArray<[number, number]>): Array<[number, nu
return kept;
}
/**
* 잘린 자리 **측벽**(2026-08-27 사용자) — 절취 윤곽을 z로 밀어내 성토면에서 원지반까지
* 세우고, 성토면과 **같은 메쉬**에 담는다(`trimTris`로 함께 그려진다).
*
* ponytail: 벽은 격자 모서리를 타는 윤곽(계단 6cm × 0.2m)을 그대로 쓴다 — 걸친 셀 안에서
* 실제 절단선은 교점 직선이라 최대 한 셀만큼 어긋난다. 화면에서 틈이 보이면 그때
* 교점 직선으로 세우면 된다.
*/
function pushSkirt(
loop: ReadonlyArray<[number, number]>,
ribbon: CorridorRibbon,
tris: number[],
uvs: number[],
): void {
const ground = ribbon.groundZ;
if (!ground) return;
const cols = ribbon.colCount;
const startChainage = ribbon.chainages[0];
/** 격자점 → [x, y, 서피스 z, 지반 z, u]. */
const at = (row: number, col: number): [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],
(ribbon.chainages[row] - startChainage) / 2,
];
};
const push = (x: number, y: number, z: number, u: number): void => {
tris.push(x, y, z);
uvs.push(u, z / 2); // 빗금은 2m마다 한 타일 — 세로도 실척으로 이어진다.
};
for (let i = 0; i < loop.length - 1; i += 1) {
const [x1, y1, top1, bottom1, u1] = at(loop[i][0], loop[i][1]);
const [x2, y2, top2, bottom2, u2] = at(loop[i + 1][0], loop[i + 1][1]);
if (top1 - bottom1 < 1e-6 && top2 - bottom2 < 1e-6) continue; // 높이 0이면 벽이 없다.
push(x1, y1, top1, u1);
push(x2, y2, top2, u2);
push(x2, y2, bottom2, u2);
push(x1, y1, top1, u1);
push(x2, y2, bottom2, u2);
push(x1, y1, bottom1, u1);
}
}
/**
* 성토 리본에 **셀 마스크**(+ 축 고정 커브가 걸친 셀의 조각 삼각형)를 달아 돌려준다.
* 원본 정점은 안 건드린다. 아무것도 안 걸리면 리본을 그대로 통과시킨다.
@@ -372,6 +418,7 @@ export function maskFillByPlanCurves(
traceMaskLoops(rows, cols, mask).forEach((raw) => {
const loop = dropCollinear(raw);
if (loop.length < 4 || !planes.length) return;
pushSkirt(loop, ribbon, tris, uvs);
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) =>