feat(B05): 날개벽 사다리꼴을 절취 영역에 합치고 병합 커브를 새 데이텀에 낸다

날개벽이 있는 구조물(세월교·BOX암거)도 절취한다. 평면 스케치에 이미 사다리꼴
(`wing-box`)이 있으므로 **비탈 투영선과 XY 합집합**으로 묶어 한 번에 자른다
(2026-08-27 사용자). 어디가 잘리는지 눈으로 보려고, 잘린 자리 윤곽을 **현 평면
대비 +3m 새 데이텀**에 `cut-merged` 커브로 따로 낸다. 기존 평면 커브는 그대로 둔다.

- Cut: `CUT_SOURCES`에 `wing-box` 추가. 마스크된 셀 덩어리의 격자 윤곽을 추적해
  (`traceMaskLoops` + 직선 구간 점 버리기) 병합 커브를 만든다. 반환형이
  `{ribbons, curves}`로 바뀐다.
- Plan: `cut-merged` 소스 추가. Mesh: 라임 `0x84cc16`.
- Build: Cut이 돌려준 커브를 planCurves 뒤에 이어 붙인다.
- Corridor: BUILD_VERSION 51 → 52.

검증(실빌드 데이터):
- 마스크 셀 우 6,611→7,856(+1,245)·좌 6,237→6,925(+688). 조각 삼각형 711→814·728→787.
- 합집합 기준 안쪽인데 남은 셀 0, 완전 바깥인데 지운 셀 0(좌·우).
- 병합 커브 8개(좌우×4세트), z가 전부 세트 평면 +3.00m 정확
  (542.05→545.05 / 535.6→538.6 / 540.2→543.2 / 544.76→547.76).
- 화면: BOX·세월교 절취부가 날개 따라 사다리꼴로 벌어지고 라임 커브가 그 윤곽을 덮는다.
- npm run typecheck 통과, prettier 적용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 09:59:10 +09:00
co-authored by Claude Opus 5
parent f4a3cb7b44
commit 2daaf127b8
5 changed files with 134 additions and 10 deletions
+3 -2
View File
@@ -99,8 +99,9 @@ function fnv1a(text: string): string {
// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26).
// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
// 51 = 절단 실패 셀을 안쪽 면적 비율로 가른다 — 경계 잔여 메쉬 제거(2026-08-27).
const BUILD_VERSION = 51;
// 52 = 날개벽 사다리꼴을 절취 영역에 합치고, 잘린 자리 윤곽을 새 데이텀(+3m)에
// `cut-merged` 커브로 낸다(2026-08-27).
const BUILD_VERSION = 52;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+6 -4
View File
@@ -637,13 +637,15 @@ export function buildCorridor(
// 없어져 커브가 통째로 사라진다(2026-08-26).
const planCurves = buildPlanCurves(crossSections, structures, frameAt, outline, ribbons);
// 커브 안쪽 성토면은 **셀 마스크**로 지운다 — 행을 잘라 내면 결국 노선 기준이
// 되어 비스듬한 커브에서 어긋난다(2026-08-26 사용자 지적, `_Cut.ts` 머리말).
// 잘린 자리 윤곽(`cut-merged`)은 새 데이텀에 얹어 기존 커브 **뒤에 덧붙인다**.
const cut = FILL_CUT_APPLY ? maskFillByPlanCurves(ribbons, planCurves) : null;
return {
// 커브 안쪽 성토면은 **셀 마스크**로 지운다 — 행을 잘라 내면 결국 노선 기준이
// 되어 비스듬한 커브에서 어긋난다(2026-08-26 사용자 지적, `_Cut.ts` 머리말).
ribbons: FILL_CUT_APPLY ? maskFillByPlanCurves(ribbons, planCurves) : ribbons,
ribbons: cut ? cut.ribbons : ribbons,
outline,
caps,
structures,
planCurves,
planCurves: cut ? [...planCurves, ...cut.curves] : planCurves,
};
}
+119 -3
View File
@@ -198,25 +198,113 @@ function coverage(areas: ReadonlyArray<CurveArea>, ring: ReadonlyArray<Corner>):
return hits / (STEPS * STEPS);
}
/** 절취 영역이 되는 커브 — 비탈 투영선과 **날개벽 사다리꼴**의 합집합(2026-08-27 사용자). */
const CUT_SOURCES: ReadonlyArray<PlanCurve["source"]> = ["slope-projected", "wing-box"];
/** 병합 커브를 올릴 **새 데이텀** — 그 세트 평면 대비 이만큼 위(2026-08-27 사용자). */
const MERGED_LIFT_M = 3;
/** 세트 평면 — 병합 커브를 어느 높이·어느 유입유출로 낼지 정한다. */
interface SetPlane {
chainage: number;
planZ: number;
role: PlanCurve["role"];
}
/**
* 마스크된 셀 덩어리의 **격자 윤곽**을 고리로 추적한다 — 실제로 잘리는 자리다.
* 셀 하나가 6cm × 0.2m라 계단이 그 크기를 넘지 않는다.
*/
function traceMaskLoops(
rows: number,
cols: number,
mask: Uint8Array,
): Array<Array<[number, number]>> {
const on = (row: number, col: number): boolean =>
row >= 0 && col >= 0 && row < rows - 1 && col < cols - 1 && mask[row * (cols - 1) + col] === 1;
const key = (row: number, col: number): number => row * cols + col;
const edges = new Map<number, Array<[number, number]>>();
const push = (fromRow: number, fromCol: number, toRow: number, toCol: number): void => {
const list = edges.get(key(fromRow, fromCol)) ?? [];
list.push([toRow, toCol]);
edges.set(key(fromRow, fromCol), list);
};
// 마스크된 셀에서 **이웃이 안 마스크된 쪽 모서리**만 담는다 — 링 방향을 맞춰 담아야
// 이어 붙일 때 한 방향으로 돈다.
for (let row = 0; row < rows - 1; row += 1) {
for (let col = 0; col < cols - 1; col += 1) {
if (!on(row, col)) continue;
if (!on(row - 1, col)) push(row, col, row, col + 1);
if (!on(row, col + 1)) push(row, col + 1, row + 1, col + 1);
if (!on(row + 1, col)) push(row + 1, col + 1, row + 1, col);
if (!on(row, col - 1)) push(row + 1, col, row, col);
}
}
const loops: Array<Array<[number, number]>> = [];
for (const [start, list] of edges) {
while (list.length) {
const head: [number, number] = [Math.floor(start / cols), start % cols];
const loop: Array<[number, number]> = [head];
let current = list.pop() as [number, number];
for (let guard = 0; guard < 100000; guard += 1) {
loop.push(current);
if (current[0] === head[0] && current[1] === head[1]) break;
const next = edges.get(key(current[0], current[1]));
if (!next?.length) break;
current = next.pop() as [number, number];
}
if (loop.length >= 4) loops.push(loop);
}
}
return loops;
}
/** 같은 방향으로 이어지는 격자 점은 버린다 — 직선 구간이 점 수천 개가 되지 않게. */
function dropCollinear(loop: ReadonlyArray<[number, number]>): Array<[number, number]> {
const kept: Array<[number, number]> = [];
for (let i = 0; i < loop.length; i += 1) {
const prev = loop[(i - 1 + loop.length) % loop.length];
const next = loop[(i + 1) % loop.length];
if (
loop[i][0] - prev[0] === next[0] - loop[i][0] &&
loop[i][1] - prev[1] === next[1] - loop[i][1]
) {
continue;
}
kept.push(loop[i]);
}
return kept;
}
/**
* 성토 리본에 **셀 마스크**(+ 축 고정 커브가 걸친 셀의 조각 삼각형)를 달아 돌려준다.
* 원본 정점은 안 건드린다. 아무것도 안 걸리면 리본을 그대로 통과시킨다.
*
* 절취 영역은 비탈 투영선 ∪ 날개벽 사다리꼴이고, 실제로 잘린 자리를 **병합 커브**
* (`cut-merged`)로 함께 돌려준다 — 기존 커브는 그대로 두고 새 데이텀(+3m)에 얹는다.
*/
export function maskFillByPlanCurves(
ribbons: ReadonlyArray<CorridorRibbon>,
curves: ReadonlyArray<PlanCurve>,
): CorridorRibbon[] {
): { ribbons: CorridorRibbon[]; curves: PlanCurve[] } {
const areasBySide = new Map<string, CurveArea[]>();
const planesBySide = new Map<string, SetPlane[]>();
for (const curve of curves) {
if (curve.source !== "slope-projected") continue;
if (!CUT_SOURCES.includes(curve.source)) continue;
const bucket = areasBySide.get(curve.side) ?? [];
curve.loops.forEach((loop) => {
if (loop.length >= 4) bucket.push(areaOf(loop));
});
areasBySide.set(curve.side, bucket);
const planes = planesBySide.get(curve.side) ?? [];
if (!planes.some((plane) => plane.chainage === curve.setChainageM)) {
planes.push({ chainage: curve.setChainageM, planZ: curve.planZ, role: curve.role });
}
planesBySide.set(curve.side, planes);
}
return ribbons.map((ribbon) => {
const merged: PlanCurve[] = [];
const cut = ribbons.map((ribbon) => {
const areas = areasBySide.get(ribbon.side);
if (ribbon.kind !== "fill" || ribbon.patch || !areas?.length) return ribbon;
const rows = ribbon.chainages.length;
@@ -277,10 +365,38 @@ export function maskFillByPlanCurves(
}
}
if (!masked) return ribbon;
// 잘린 자리 윤곽을 **새 데이텀**에 얹어 따로 낸다 — 어디가 잘리는지 눈으로 본다.
// 성토 리본은 좌·우뿐이라 커브의 측 표기와 그대로 맞는다.
const side: PlanCurve["side"] = ribbon.side === "right" ? "right" : "left";
const planes = planesBySide.get(ribbon.side) ?? [];
traceMaskLoops(rows, cols, mask).forEach((raw) => {
const loop = dropCollinear(raw);
if (loop.length < 4 || !planes.length) return;
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) =>
Math.abs(item.chainage - chainage) < Math.abs(best.chainage - chainage) ? item : best,
);
const z = plane.planZ + MERGED_LIFT_M;
merged.push({
setChainageM: plane.chainage,
source: "cut-merged",
role: plane.role,
side,
planZ: z,
loops: [
loop.map(([row, col]) => {
const [x, y] = point(row, col);
return [x, y, z] as [number, number, number];
}),
],
});
});
return {
...ribbon,
cellMask: mask,
...(tris.length ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } : {}),
};
});
return { ribbons: cut, curves: merged };
}
@@ -44,6 +44,8 @@ const PLAN_CURVE_COLORS: Record<PlanCurve["source"], number> = {
"wing-box": 0xff8c1a,
// 스케치 파란 커브를 절·성토면에 얹은 투영선 — 빨강(2026-08-26 사용자 지정).
"slope-projected": 0xff3b30,
// 실제로 잘린 자리 윤곽(2026-08-27 사용자) — 새 데이텀(+3m)에 얹어 라임으로 낸다.
"cut-merged": 0x84cc16,
};
/** 유출측 파선 눈금(m) — 유입은 실선이다. */
+4 -1
View File
@@ -35,7 +35,10 @@ export type PlanCurveSource =
| "wing-box"
/** 파란 스케치 커브(`slope-original`)를 절·성토면에 탑뷰 투영한 **빨간 선**
* (2026-08-26 사용자). 스케치는 평면에 그대로 남고 이 커브만 서피스에 얹힌다. */
| "slope-projected";
| "slope-projected"
/** 실제로 잘린 자리 윤곽(2026-08-27 사용자) — 비탈 투영선 ∪ 날개벽 사다리꼴을 합쳐
* 셀 마스크에서 되짚은 커브. **현 평면 대비 +3m 새 데이텀**에 얹는다. */
| "cut-merged";
/** 투영 커브 한 벌 — 한 구조물 세트의 한 측(유입 또는 유출), 한 종류. */
export interface PlanCurve {