feat(B05): 투영 커브 안쪽 성토면을 셀 단위로 지운다

빨간 투영선이 감싸는 자리의 성토면을 지운다. 지울 영역은 결국 **상단 평면 스케치가
덮는 XY 영역** 하나이고 투영선이 그 XY를 그대로 복사한 것이므로, 구조물 종류를
가리지 않고 한 가지 방법으로 자른다(사용자 확정).

- Cut(신규): 격자 셀 중심·귀퉁이로 안팎을 가려 안쪽 셀은 마스크하고, 걸친 셀은
  교점 두 개가 만드는 직선으로 잘라 바깥 조각만 삼각형으로 되살린다. 정점은
  하나도 안 건드린다. 교점이 2개 미만이면 셀을 통째로 남긴다 — 모자라게 자를지언정
  더 자르지 않는다.
- Build: CorridorRibbon에 cellMask·trimTris·trimUvs 추가.
- Mesh: 마스크된 셀의 삼각형을 빼고, 조각 삼각형을 뒤에 이어 붙인다.
- Plan: BOX암거 커브의 **안쪽 끝도 박스 축**으로 쏜다(boxInner + castToToe).
  종전엔 바깥만 박스 축이고 안쪽은 노선 흐름이라 가로대가 비스듬했다 —
  평면 스케치의 횡단 방향은 박스가 놓인 전역 단방향이어야 한다(사용자 확정).
- Corridor: 저장본에 마스크·조각 삼각형을 담고 BUILD_VERSION을 올린다.

검증(공용 브라우저 캐시 데이터 실측):
- BOX 커브 가로대 각도차 0.00°(좌·우) — 전역 단방향 확인. 다른 세트는 노선 흐름대로
  1.1~48.8°.
- 걸친 셀에서 되살린 조각 면적이 기대치와 일치 — 우 2.07㎡ vs 2.07, 좌 4.60 vs 4.61.
- 커브 밖을 지운 셀 0. 도로부·측구·절토 리본 행 수 불변(1768/1768/1768/15/26/23/13).

남은 것: 걸친 셀 1,230개 중 약 600개가 교점 부족으로 통째로 남아 경계에 잔여가
생긴다(우 2.09㎡·좌 4.36㎡). 더 잘리는 문제는 없다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:32:35 +09:00
co-authored by Claude Opus 5
parent 398b5bdffd
commit 753e8fcb44
5 changed files with 390 additions and 23 deletions
+43 -7
View File
@@ -30,14 +30,11 @@ export interface CorridorStructureHook {
}
/**
* 저장본 형식 버전 — 올리면 구 저장본을 버리고 한 번 다시 빌드한다.
* 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다.
*
* ⚠ 저장본은 **입력 해시(측점·노선)로만** 무효화된다. 코리도 기하 코드를 고쳐도
* 입력이 그대로면 옛 저장본이 계속 복원되어 **수정이 화면에 전혀 안 나타난다**
* (2026-08-26 반나절 허비). 기하를 건드렸으면 이 번호를 반드시 함께 올릴 것.
* 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다.
*
* 3 = 탑뷰 투영선(slope-projected) + 구조물 구간 경계 행 + 파란 스케치 안쪽 기준을
* 절·성토면 시작점으로 정정(노견 확장·측구 반영) — 2026-08-26.
* 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26.
*/
const ENVELOPE_VERSION = 3;
@@ -50,6 +47,11 @@ interface CorridorEnvelope {
colCount: number;
chainages: number[];
positionsBase64: string;
/** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */
cellMaskBase64?: string;
/** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */
trimTrisBase64?: string;
trimUvsBase64?: string;
}>;
outline: CorridorBuildResult["outline"];
/** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */
@@ -93,7 +95,11 @@ function fnv1a(text: string): string {
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
* 섞어 두면 배포와 동시에 저장본이 만료된다.
*/
const BUILD_VERSION = 40; // 세월교 날개·에이프런을 종단경사에 태움(2026-08-26).
// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정·
// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26).
// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
const BUILD_VERSION = 50;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
@@ -137,6 +143,22 @@ function base64FromFloats(values: Float64Array): string {
return btoa(binary);
}
function base64FromBytes(bytes: Uint8Array): string {
let binary = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
function bytesFromBase64(encoded: string): Uint8Array {
const binary = atob(encoded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function floatsFromBase64(encoded: string): Float64Array {
const binary = atob(encoded);
const bytes = new Uint8Array(binary.length);
@@ -154,6 +176,13 @@ function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
colCount: ribbon.colCount,
chainages: ribbon.chainages,
positionsBase64: base64FromFloats(ribbon.positions),
...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}),
...(ribbon.trimTris && ribbon.trimUvs
? {
trimTrisBase64: base64FromFloats(ribbon.trimTris),
trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))),
}
: {}),
})),
outline: build.outline,
caps: build.caps,
@@ -170,6 +199,13 @@ function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
colCount: ribbon.colCount,
chainages: ribbon.chainages,
positions: floatsFromBase64(ribbon.positionsBase64),
...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}),
...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64
? {
trimTris: floatsFromBase64(ribbon.trimTrisBase64),
trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer),
}
: {}),
})),
outline: envelope.outline,
caps: envelope.caps ?? [],
+47 -13
View File
@@ -48,6 +48,7 @@ export type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corrido
import { buildStructurePatchRibbons } from "./B05_Profile_UI_Corridor_Carve";
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
import { buildPlanCurves, type PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
import { maskFillByPlanCurves } from "./B05_Profile_UI_Corridor_Cut";
export type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
/** 리본 하나 — rows[i]는 프레임 i의 단면 점(colCount개, 모델 x/y/z 평탄 배열). */
@@ -69,6 +70,19 @@ export interface CorridorRibbon {
* 생겼다(2026-08-23 실측). 화면에 올릴 때 원점을 빼면서 Float32로 줄인다.
*/
positions: Float64Array;
/**
* 셀 마스크 — `(rowCount-1) × (colCount-1)`, 1이면 **그 셀은 안 그린다**
* (2026-08-26 투영 커브 안쪽 지우기). 정점은 그대로 두고 삼각형만 뺀다.
*/
cellMask?: Uint8Array;
/**
* 축 고정 커브(BOX암거)가 **걸친 셀**을 커브에서 잘라 되살린 조각 삼각형
* (모델 좌표, 삼각형당 9). 격자로는 못 담는 비스듬한 경계를 여기로 메운다
* (2026-08-26 사용자: 이원화하여 관리).
*/
trimTris?: Float64Array;
/** `trimTris`와 짝인 UV(삼각형당 6) — 빗금이 실척으로 이어지게 한다. */
trimUvs?: Float32Array;
}
/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */
@@ -111,20 +125,33 @@ const SUBDIVIDE_STEP_M = 0.2;
*/
const STRUCTURE_SPAN_STEP_M = SUBDIVIDE_STEP_M;
/**
* 투영 커브 안쪽 **성토면 도려내기** 적용 여부(2026-08-26 사용자). 끄면 커브만 얹고
* 서피스는 그대로 둔다. 절토면은 대상이 아니다(사용자: 성토면만).
*/
const FILL_CUT_APPLY: boolean = true;
/**
* 구조물 점유 구간의 시작·끝 누가거리 — 여기를 **행 경계로 강제**해야 탑뷰 투영
* 커브가 격자 모서리 위에 앉는다. 값은 `_Corridor_Plan.ts`의 비탈 커브 구간과 같은
* 출처(`structureSilhouettes`의 전/후 몫)를 쓴다 — 두 곳이 어긋나면 커브 끝이 다시
* 셀 중간에 뜬다.
*/
function structureSpanBounds(crossSections: ReadonlyArray<CrossSection>): Array<[number, number]> {
const spans: Array<[number, number]> = [];
interface StructureSpanRange {
side: CorridorSide;
from: number;
to: number;
}
function structureSpanBounds(crossSections: ReadonlyArray<CrossSection>): StructureSpanRange[] {
const spans: StructureSpanRange[] = [];
for (const section of crossSections) {
for (const silhouette of structureSilhouettes(section)) {
spans.push([
section.chainage_m - silhouette.span.beforeM,
section.chainage_m + silhouette.span.afterM,
]);
spans.push({
side: silhouette.side,
from: section.chainage_m - silhouette.span.beforeM,
to: section.chainage_m + silhouette.span.afterM,
});
}
}
return spans;
@@ -140,7 +167,7 @@ function rowFractions(
spanM: number,
steps: number,
includeEnd: boolean,
spans: ReadonlyArray<[number, number]>,
spans: ReadonlyArray<StructureSpanRange>,
): number[] {
const ts: number[] = [];
for (let k = 0; k <= (includeEnd ? steps : steps - 1); k += 1) ts.push(k / steps);
@@ -150,7 +177,7 @@ function rowFractions(
const t = (chainageM - startChainageM) / spanM;
if (t > 1e-9 && t < 1 - 1e-9) ts.push(t);
};
for (const [from, to] of spans) {
for (const { from, to } of spans) {
if (to <= startChainageM || from >= endChainageM) continue;
put(from);
put(to);
@@ -602,14 +629,21 @@ export function buildCorridor(
);
}
// 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌
// 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다.
// 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다.
//
// **자르기보다 먼저** 내야 한다 — 성토면을 먼저 도려내면 투영선이 z를 읽을 면이
// 없어져 커브가 통째로 사라진다(2026-08-26).
const planCurves = buildPlanCurves(crossSections, structures, frameAt, outline, ribbons);
return {
ribbons,
// 커브 안쪽 성토면은 **셀 마스크**로 지운다 — 행을 잘라 내면 결국 노선 기준이
// 되어 비스듬한 커브에서 어긋난다(2026-08-26 사용자 지적, `_Cut.ts` 머리말).
ribbons: FILL_CUT_APPLY ? maskFillByPlanCurves(ribbons, planCurves) : ribbons,
outline,
caps,
structures,
// 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌
// 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다.
// 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다.
planCurves: buildPlanCurves(crossSections, structures, frameAt, outline, ribbons),
planCurves,
};
}
+250
View File
@@ -0,0 +1,250 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Cut.ts
* 탑뷰 투영 커브(빨간 선) **안쪽 성토면 지우기** — 2026-08-26 사용자.
*
* 방식: 정점은 **하나도 건드리지 않는다**. 격자 셀(사각형) 중 **중심이 커브 안에
* 드는 셀만 마스크**해 그리기 단계에서 삼각형을 빼면 그 자리가 비워진다.
*
* 왜 이렇게(2026-08-26 사용자 지적): 리본은 `행 × 열` 직사각 격자라 한 행 안에
* 들쭉날쭉한 구멍을 담지 못한다. 그래서 종전처럼 행을 잘라 내는 방식은 결국
* **행 경계 = 노선 기준**으로 떨어져, BOX암거처럼 커브가 노선과 비스듬한 자리에서
* 파란선 바깥까지 잘려 나갔다(실측: 좌측 커브 바깥 변은 200.37~201.91인데 구멍은
* 200.18~202.11). 셀 마스크는 그 한계가 없다 — 리샘플·조각 이어붙이기도 필요 없다.
*
* **선택 영역은 하나다**(2026-08-26 사용자): 지울 자리는 결국 상단 평면 스케치가
* 덮는 XY 영역이고, 투영선은 그 XY를 그대로 복사한 것(Z만 서피스에서 읽음)이다.
* 그래서 구조물 종류를 가리지 않고 **한 가지 방법**으로 자른다 — 종전에 노선 흐름
* 커브와 축 고정 커브(BOX)를 나눠 다루려 했으나, 영역이 같은데 방법만 갈라 두면
* 한쪽은 계단, 다른 쪽은 지그재그가 됐다.
*
* 클리핑은 **셀 하나를 직선 하나로** 자른다. 셀은 폭 6cm·길이 0.2m라 그 안에서 커브는
* 사실상 직선이고, 볼록 사각형을 직선으로 자르면 결과도 볼록이라 부채꼴 삼각분할이
* 항상 옳다. 다각형 대 다각형 클리핑을 시도했다가 되살릴 면적의 40%를 잃고 톱니가
* 났던 전례가 있다(2026-08-26 실측) — 교점 두 개로 직선을 세우는 지금 방식이 그 함정을
* 피한다. 교점이 2개가 아니면 셀을 **통째로 남긴다**(모자라게 자를지언정 더 자르지 않는다).
*
* 잘린 자리는 비워 둔다 — 구조물 솔리드가 들어앉아 메운다(2026-08-25 사용자 확정).
* 절토면·도로부·원지반은 건드리지 않는다(사용자: 성토면만).
* ========================================================================== */
import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build";
import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
/** 커브 다각형 하나 — XY와 경계 상자(빠른 배제용). */
interface CurveArea {
points: Array<[number, number]>;
minX: number;
maxX: number;
minY: number;
maxY: number;
}
/** 광선 투사 내부 판정(XY). */
function inside(area: CurveArea, x: number, y: number): boolean {
if (x < area.minX || x > area.maxX || y < area.minY || y > area.maxY) return false;
const points = area.points;
let hit = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
const [xi, yi] = points[i];
const [xj, yj] = points[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
}
return hit;
}
/** 셀 네 귀퉁이 — 링 순서(row,col) → (row,col+1) → (row+1,col+1) → (row+1,col). */
interface Corner {
p: [number, number, number];
/** 셀 안 상대좌표 — a: 열 방향(0~1), b: 행 방향(0~1). UV 보간에 쓴다. */
a: number;
b: number;
}
function areaOf(loop: ReadonlyArray<[number, number, number]>): CurveArea {
const points = loop.map((point) => [point[0], point[1]] as [number, number]);
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const [x, y] of points) {
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
return { points, minX, maxX, minY, maxY };
}
/** 셀 둘레와 커브 경계의 교점(둘레 진행 순서). */
function ringCrossings(areas: ReadonlyArray<CurveArea>, ring: ReadonlyArray<Corner>): Corner[] {
const hits: Corner[] = [];
for (let i = 0; i < ring.length; i += 1) {
const from = ring[i];
const to = ring[(i + 1) % ring.length];
const rx = to.p[0] - from.p[0];
const ry = to.p[1] - from.p[1];
const ts: number[] = [];
for (const area of areas) {
const points = area.points;
for (let k = 0, j = points.length - 1; k < points.length; j = k, k += 1) {
const sx = points[k][0] - points[j][0];
const sy = points[k][1] - points[j][1];
const denominator = rx * sy - ry * sx;
if (Math.abs(denominator) < 1e-12) continue;
const dx = points[j][0] - from.p[0];
const dy = points[j][1] - from.p[1];
const t = (dx * sy - dy * sx) / denominator;
const u = (dx * ry - dy * rx) / denominator;
if (t >= -1e-9 && t <= 1 + 1e-9 && u >= 0 && u <= 1) ts.push(Math.min(1, Math.max(0, t)));
}
}
ts.sort((p, q) => p - q);
for (const t of ts) {
const last = hits[hits.length - 1];
const point: Corner = {
p: [from.p[0] + rx * t, from.p[1] + ry * t, from.p[2] + (to.p[2] - from.p[2]) * t],
a: from.a + (to.a - from.a) * t,
b: from.b + (to.b - from.b) * t,
};
// 같은 자리 중복(모서리 통과) 제거.
if (last && Math.hypot(last.p[0] - point.p[0], last.p[1] - point.p[1]) < 1e-6) continue;
hits.push(point);
}
}
return hits;
}
/**
* 셀을 교점 두 개가 만드는 **직선 하나로** 자르고, 커브 **바깥** 쪽 조각을 돌려준다.
* 교점이 2개가 아니면 null — 부르는 쪽이 셀을 통째로 남긴다.
*/
function clipCellByChord(
areas: ReadonlyArray<CurveArea>,
ring: ReadonlyArray<Corner>,
): Corner[] | null {
const hits = ringCrossings(areas, ring);
// 교점은 보통 2개다. 귀퉁이를 스치거나 커브 마디가 셀 안에서 꺾이면 더 나오는데,
// 그때도 **처음과 마지막**을 이으면 셀 안 경계와 거의 같다(셀이 0.2m × 6cm라
// 그 안에서 커브는 사실상 직선). 2개일 때만 자르면 3분의 2가 통째로 남아 경계가
// 다시 지그재그가 됐다(2026-08-26 실측: 걸친 셀 1,230개 중 816개가 미절단).
if (hits.length < 2) return null;
const h0 = hits[0];
const h1 = hits[hits.length - 1];
const lx = h1.p[0] - h0.p[0];
const ly = h1.p[1] - h0.p[1];
if (Math.hypot(lx, ly) < 1e-9) return null;
/** 직선 왼쪽/오른쪽 부호. */
const sideOf = (x: number, y: number): number => lx * (y - h0.p[1]) - ly * (x - h0.p[0]);
// 바깥 쪽 부호 — 커브 밖인 귀퉁이로 정한다.
let keepSign = 0;
for (const corner of ring) {
if (areas.some((area) => inside(area, corner.p[0], corner.p[1]))) continue;
const value = sideOf(corner.p[0], corner.p[1]);
if (Math.abs(value) > 1e-9) {
keepSign = Math.sign(value);
break;
}
}
if (!keepSign) return null;
// 볼록 사각형을 직선으로 자른다 — 결과도 볼록이라 부채꼴 삼각분할이 항상 옳다.
const kept: Corner[] = [];
for (let i = 0; i < ring.length; i += 1) {
const from = ring[i];
const to = ring[(i + 1) % ring.length];
const sFrom = sideOf(from.p[0], from.p[1]) * keepSign;
const sTo = sideOf(to.p[0], to.p[1]) * keepSign;
if (sFrom >= -1e-9) kept.push(from);
if ((sFrom > 0 && sTo < 0) || (sFrom < 0 && sTo > 0)) {
const t = sFrom / (sFrom - sTo);
kept.push({
p: [
from.p[0] + (to.p[0] - from.p[0]) * t,
from.p[1] + (to.p[1] - from.p[1]) * t,
from.p[2] + (to.p[2] - from.p[2]) * t,
],
a: from.a + (to.a - from.a) * t,
b: from.b + (to.b - from.b) * t,
});
}
}
return kept.length >= 3 ? kept : null;
}
/**
* 성토 리본에 **셀 마스크**(+ 축 고정 커브가 걸친 셀의 조각 삼각형)를 달아 돌려준다.
* 원본 정점은 안 건드린다. 아무것도 안 걸리면 리본을 그대로 통과시킨다.
*/
export function maskFillByPlanCurves(
ribbons: ReadonlyArray<CorridorRibbon>,
curves: ReadonlyArray<PlanCurve>,
): CorridorRibbon[] {
const areasBySide = new Map<string, CurveArea[]>();
for (const curve of curves) {
if (curve.source !== "slope-projected") continue;
const bucket = areasBySide.get(curve.side) ?? [];
curve.loops.forEach((loop) => {
if (loop.length >= 4) bucket.push(areaOf(loop));
});
areasBySide.set(curve.side, bucket);
}
return ribbons.map((ribbon) => {
const areas = areasBySide.get(ribbon.side);
if (ribbon.kind !== "fill" || ribbon.patch || !areas?.length) return ribbon;
const rows = ribbon.chainages.length;
const cols = ribbon.colCount;
if (rows < 2 || cols < 2) return ribbon;
const mask = new Uint8Array((rows - 1) * (cols - 1));
let masked = 0;
const tris: number[] = [];
const uvs: number[] = [];
const startChainage = ribbon.chainages[0];
const point = (row: number, col: number): [number, number, number] => {
const i = (row * cols + col) * 3;
return [ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]];
};
const uvOf = (row: number, corner: Corner): [number, number] => {
const chainage =
ribbon.chainages[row] + (ribbon.chainages[row + 1] - ribbon.chainages[row]) * corner.b;
return [(chainage - startChainage) / 2, corner.a / Math.max(1, cols - 1)];
};
for (let row = 0; row < rows - 1; row += 1) {
for (let col = 0; col < cols - 1; col += 1) {
const ring: Corner[] = [
{ p: point(row, col), a: col, b: 0 },
{ p: point(row, col + 1), a: col + 1, b: 0 },
{ p: point(row + 1, col + 1), a: col + 1, b: 1 },
{ p: point(row + 1, col), a: col, b: 1 },
];
// 귀퉁이가 하나도 안쪽이 아니면 그대로 둔다.
const inCount = ring.filter((corner) =>
areas.some((area) => inside(area, corner.p[0], corner.p[1])),
).length;
if (inCount === 0) continue;
if (inCount === 4) {
mask[row * (cols - 1) + col] = 1;
masked += 1;
continue;
}
const kept = clipCellByChord(areas, ring);
// 교점이 2개가 아니면 셀을 통째로 남긴다 — 모자라게 자를지언정 더 자르지 않는다.
if (!kept) continue;
mask[row * (cols - 1) + col] = 1;
masked += 1;
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]);
const [u, v] = uvOf(row, corner);
uvs.push(u, v);
}
}
}
}
if (!masked) return ribbon;
return {
...ribbon,
cellMask: mask,
...(tris.length ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } : {}),
};
});
}
+24 -2
View File
@@ -133,8 +133,12 @@ function ribbonGeometry(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.Buff
}
}
const indices: number[] = [];
// 셀 마스크가 선 자리는 삼각형을 빼 그 자리를 비운다 — 투영 커브 안쪽 지우기
// (2026-08-26). 정점은 그대로 두므로 남는 면의 형상은 하나도 안 바뀐다.
const mask = ribbon.cellMask;
for (let row = 0; row < rowCount - 1; row += 1) {
for (let col = 0; col < cols - 1; col += 1) {
if (mask?.[row * (cols - 1) + col]) continue;
const a = row * cols + col;
const b = a + 1;
const c = a + cols;
@@ -142,9 +146,27 @@ function ribbonGeometry(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.Buff
indices.push(a, c, b, b, c, d);
}
}
// 축 고정 커브가 걸친 셀은 격자로 못 담는다 — 잘라 되살린 조각 삼각형을 뒤에 붙인다.
const trim = ribbon.trimTris;
const trimCount = trim ? trim.length / 3 : 0;
const allPositions = new Float32Array((rowCount * cols + trimCount) * 3);
const allUvs = new Float32Array((rowCount * cols + trimCount) * 2);
allPositions.set(positions);
allUvs.set(uvs);
if (trim) {
const base = rowCount * cols;
for (let v = 0; v < trimCount; v += 1) {
allPositions[(base + v) * 3] = trim[v * 3] - origin.cx;
allPositions[(base + v) * 3 + 1] = trim[v * 3 + 2] - origin.cz;
allPositions[(base + v) * 3 + 2] = -(trim[v * 3 + 1] - origin.cy);
allUvs[(base + v) * 2] = ribbon.trimUvs?.[v * 2] ?? 0;
allUvs[(base + v) * 2 + 1] = ribbon.trimUvs?.[v * 2 + 1] ?? 0;
if (v % 3 === 2) indices.push(base + v - 2, base + v - 1, base + v);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
geometry.setAttribute("position", new THREE.BufferAttribute(allPositions, 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(allUvs, 2));
geometry.setIndex(indices);
geometry.computeVertexNormals();
return geometry;
+26 -1
View File
@@ -48,6 +48,12 @@ export interface PlanCurve {
planZ: number;
/** 모델 좌표 닫힌 고리들(z는 전부 planZ). */
loops: Array<Array<[number, number, number]>>;
/**
* 가로 방향이 **노선이 아니라 구조물 축에 고정**된 커브(BOX암거). 커브가 코리도
* 격자와 비스듬해 셀 마스크만으로는 경계가 지그재그가 된다 — 절단 쪽에서 이 커브만
* 골라 셀을 커브로 쪼갠다(2026-08-26 사용자: 이원화하여 관리).
*/
axisLocked?: boolean;
}
/** 구조물 상단에서 투영 평면을 띄우는 높이(m) — 2026-08-26 사용자. */
@@ -364,6 +370,7 @@ export function buildPlanCurves(
side,
planZ,
loops,
...(boxAxis ? { axisLocked: true } : {}),
});
};
@@ -430,13 +437,31 @@ export function buildPlanCurves(
// 대신 고정 방향으로 쏜 선이 성토 끝선과 만나는 자리를 바깥 끝으로 삼는다 —
// 끝은 여전히 실제 성토 끝선 위다(2026-08-26 사용자).
const boxToe = boxAxis ? nearToe(outline, side, section.chainage_m) : null;
/**
* BOX암거는 **안쪽 끝도** 박스 축 위여야 한다(2026-08-26 사용자: 평면 스케치의
* 횡단 방향은 박스가 놓인 **전역 단방향**이고 노선 흐름을 타면 안 된다).
* 바깥 끝처럼, 고정 축으로 쏜 선이 **성토면 안쪽 끝선**과 만나는 자리를 쓴다 —
* 한쪽만 축을 맞추면 가로대가 행을 가로질러 비스듬해진다.
*/
const boxInner = boxAxis
? outline.chainages
.filter((chainageM) => Math.abs(chainageM - section.chainage_m) <= TOE_WINDOW_M)
.map((chainageM) => {
const routeFrame = routeFrameOf(chainageM);
const edge = at(routeFrame, roadEdge);
const start = surface.innerAt(chainageM, side, edge.x, edge.y) ?? edge;
return [start.x, start.y] as [number, number];
})
: null;
/** 같은 행을 절·성토면 표고에 얹은 것 — 빨간 투영선(2026-08-26 사용자). */
const projected: Array<[[number, number, number], [number, number, number]] | null> = [];
outline.chainages.forEach((chainageM, index) => {
if (chainageM < from - 1e-9 || chainageM > to + 1e-9) return;
const frame = frameOf(chainageM);
const edgeXY = at(frame, roadEdge);
const surfaceStart = surface.innerAt(chainageM, side, edgeXY.x, edgeXY.y);
const surfaceStart = boxInner
? castToToe(frame.cx, frame.cy, frame.leftX * sign, frame.leftY * sign, boxInner)
: surface.innerAt(chainageM, side, edgeXY.x, edgeXY.y);
let outerXY: PXY;
if (boxToe) {
const hit = castToToe(frame.cx, frame.cy, frame.leftX * sign, frame.leftY * sign, boxToe);