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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) — 유입은 실선이다. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user