feat(B05): 절취 측벽을 별도 서피스로 빼고 원지반 아래 1m 여유를 준다

측벽을 성토 리본 메시에 섞지 않고 **구조물처럼 별도 서피스**로 낸다(2026-08-27
사용자). UV·법선이 리본에 묶이지 않아 다음 단계(구조물 안착 바닥면·트림)에서 따로
다룰 수 있다. 밑선은 원지반에서 **1m 더 내린다** — 지반 보간 오차만큼 밑이 뜨는 걸
막고, 지형은 코리도 자리에서 이미 잘려 있어 여유분은 가려진다.

- Build: `CorridorCutWall`(삼각형 수프 + UV) 추가, 빌드 결과에 `cutWalls`로 싣는다.
- Cut: 벽 삼각형을 `trimTris`에서 분리해 `walls`로 돌려준다. `SKIRT_MARGIN_M = 1`.
- Mesh: 벽마다 메쉬를 따로 만든다(`corridor-cut-wall:{측}:{번호}`) — 색·빗금은
  성토와 같아 한 몸으로 보이되 객체는 분리.
- Corridor: 저장본에 `cutWalls` 담고 ENVELOPE_VERSION 3 → 4, BUILD_VERSION 53 → 54.
- Viewer: 검증 훅 `raw`에 `cutWalls` 추가.

검증(실빌드 데이터):
- 리본 조각 삼각형 우 814·좌 787(벽 빠짐), 벽 서피스 삼각형 우 360·좌 356.
- 벽 높이 최소 1.00m(여유분) / 평균 2.26·2.87 / 최대 3.80·6.25m — 1차 대비 정확히 +1m.
- ISO 화면: BOX·세월교 절취부 옆면이 벽으로 막히고 바닥만 열려 있다(다음 작업).
- npm run typecheck 통과, prettier 적용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 10:46:19 +09:00
co-authored by Claude Opus 5
parent 86a20cccb4
commit 43804f2f5b
5 changed files with 91 additions and 12 deletions
+21 -4
View File
@@ -35,8 +35,9 @@ export interface CorridorStructureHook {
* 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다.
*
* 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26.
* 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27.
*/
const ENVELOPE_VERSION = 3;
const ENVELOPE_VERSION = 4;
interface CorridorEnvelope {
version: number;
@@ -63,6 +64,12 @@ interface CorridorEnvelope {
* 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다.
*/
planCurves?: CorridorBuildResult["planCurves"];
/** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */
cutWalls?: Array<{
side: "left" | "right";
positionsBase64: string;
uvsBase64: string;
}>;
}
interface CacheEntry {
@@ -99,9 +106,9 @@ function fnv1a(text: string): string {
// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26).
// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
// 53 = 잘린 자리 측벽 — 절취 윤곽을 z로 밀어내 성토면↔원지반을 잇는 벽을 세우고
// 성토 메쉬에 합친다(2026-08-27).
const BUILD_VERSION = 53;
// 54 = 절취 측벽을 리본 메시에서 떼어 **별도 서피스**로 내고, 원지반 아래로 여유
// 1m를 더 내린다(2026-08-27 사용자).
const BUILD_VERSION = 54;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
@@ -190,6 +197,11 @@ function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
caps: build.caps,
structures: build.structures,
planCurves: build.planCurves,
cutWalls: (build.cutWalls ?? []).map((wall) => ({
side: wall.side,
positionsBase64: base64FromFloats(wall.positions),
uvsBase64: base64FromBytes(new Uint8Array(wall.uvs.buffer.slice(0))),
})),
};
}
@@ -213,6 +225,11 @@ function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
caps: envelope.caps ?? [],
structures: envelope.structures ?? [],
planCurves: envelope.planCurves ?? [],
cutWalls: (envelope.cutWalls ?? []).map((wall) => ({
side: wall.side,
positions: floatsFromBase64(wall.positionsBase64),
uvs: new Float32Array(bytesFromBase64(wall.uvsBase64).buffer),
})),
};
}
@@ -90,6 +90,16 @@ export interface CorridorRibbon {
groundZ?: Float64Array;
}
/**
* 잘린 자리 **측벽 서피스**(2026-08-27 사용자) — 리본 메시에 섞지 않고 구조물처럼
* 따로 그린다. 삼각형 수프(정점당 3, 삼각형당 9)와 짝 UV(정점당 2).
*/
export interface CorridorCutWall {
side: "left" | "right";
positions: Float64Array;
uvs: Float32Array;
}
/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */
export interface CorridorCap {
/** [x, y, z설계, z지반] × N — offset 오름차순. */
@@ -110,6 +120,8 @@ export interface CorridorBuildResult {
* 저장 코리도엔 없을 수 있다.
*/
planCurves?: PlanCurve[];
/** 절취 측벽 서피스(2026-08-27) — 저장본에도 담는다. 없으면 빈 배열. */
cutWalls?: CorridorCutWall[];
}
/**
@@ -663,5 +675,6 @@ export function buildCorridor(
caps,
structures,
planCurves: cut ? [...planCurves, ...cut.curves] : planCurves,
cutWalls: cut ? cut.walls : [],
};
}
+23 -7
View File
@@ -29,7 +29,7 @@
* 절토면·도로부·원지반은 건드리지 않는다(사용자: 성토면만).
* ========================================================================== */
import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build";
import type { CorridorCutWall, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build";
import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
/** 커브 다각형 하나 — XY와 경계 상자(빠른 배제용). */
@@ -276,9 +276,14 @@ function dropCollinear(loop: ReadonlyArray<[number, number]>): Array<[number, nu
return kept;
}
/** 측벽을 원지반 **아래로 더 내리는 여유**(m) — 지반 보간 오차만큼 밑이 뜨는 걸 막는다.
* 지형은 코리도 자리에서 이미 잘려 있어 더 내려도 가려진다(2026-08-27 사용자). */
const SKIRT_MARGIN_M = 1;
/**
* 잘린 자리 **측벽**(2026-08-27 사용자) — 절취 윤곽을 z로 밀어내 성토면에서 원지반까지
* 세우고, 성토면과 **같은 메쉬**에 담는다(`trimTris`로 함께 그려진다).
* 잘린 자리 **측벽**(2026-08-27 사용자) — 절취 윤곽을 z로 밀어내 성토면에서 원지반
* 아래(`SKIRT_MARGIN_M`)까지 세운다. 리본 메시에 섞지 않고 **구조물처럼 별도 서피스**로
* 낸다 — UV·법선이 리본에 묶이지 않아 다음 단계(바닥면·트림)에서 따로 다룰 수 있다.
*
* ponytail: 벽은 격자 모서리를 타는 윤곽(계단 6cm × 0.2m)을 그대로 쓴다 — 걸친 셀 안에서
* 실제 절단선은 교점 직선이라 최대 한 셀만큼 어긋난다. 화면에서 틈이 보이면 그때
@@ -301,7 +306,7 @@ function pushSkirt(
ribbon.positions[i],
ribbon.positions[i + 1],
ribbon.positions[i + 2],
ground[row * cols + col],
ground[row * cols + col] - SKIRT_MARGIN_M,
(ribbon.chainages[row] - startChainage) / 2,
];
};
@@ -332,7 +337,7 @@ function pushSkirt(
export function maskFillByPlanCurves(
ribbons: ReadonlyArray<CorridorRibbon>,
curves: ReadonlyArray<PlanCurve>,
): { ribbons: CorridorRibbon[]; curves: PlanCurve[] } {
): { ribbons: CorridorRibbon[]; curves: PlanCurve[]; walls: CorridorCutWall[] } {
const areasBySide = new Map<string, CurveArea[]>();
const planesBySide = new Map<string, SetPlane[]>();
for (const curve of curves) {
@@ -350,6 +355,7 @@ export function maskFillByPlanCurves(
}
const merged: PlanCurve[] = [];
const walls: CorridorCutWall[] = [];
const cut = ribbons.map((ribbon) => {
const areas = areasBySide.get(ribbon.side);
if (ribbon.kind !== "fill" || ribbon.patch || !areas?.length) return ribbon;
@@ -360,6 +366,9 @@ export function maskFillByPlanCurves(
let masked = 0;
const tris: number[] = [];
const uvs: number[] = [];
// 측벽은 리본 메시가 아니라 **별도 서피스**로 나간다(2026-08-27 사용자).
const wallTris: number[] = [];
const wallUvs: number[] = [];
const startChainage = ribbon.chainages[0];
const point = (row: number, col: number): [number, number, number] => {
const i = (row * cols + col) * 3;
@@ -418,7 +427,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);
pushSkirt(loop, ribbon, wallTris, wallUvs);
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) =>
@@ -439,11 +448,18 @@ export function maskFillByPlanCurves(
],
});
});
if (wallTris.length) {
walls.push({
side,
positions: new Float64Array(wallTris),
uvs: new Float32Array(wallUvs),
});
}
return {
...ribbon,
cellMask: mask,
...(tris.length ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } : {}),
};
});
return { ribbons: cut, curves: merged };
return { ribbons: cut, curves: merged, walls };
}
@@ -416,6 +416,35 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou
group.add(line);
});
});
// 절취 측벽 — 리본과 분리된 **별도 서피스**(2026-08-27 사용자). 성토면과 같은 색·빗금이라
// 한 몸으로 보이지만 객체가 달라 다음 단계(바닥면·트림)에서 따로 다룰 수 있다.
(build.cutWalls ?? []).forEach((wall, index) => {
const count = wall.positions.length / 3;
if (!count) return;
const positions = new Float32Array(count * 3);
for (let v = 0; v < count; v += 1) {
positions[v * 3] = wall.positions[v * 3] - origin.cx;
positions[v * 3 + 1] = wall.positions[v * 3 + 2] - origin.cz;
positions[v * 3 + 2] = -(wall.positions[v * 3 + 1] - origin.cy);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(wall.uvs.slice(0), 2));
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshLambertMaterial({
color: KIND_COLORS.fill,
map: hatchTexture(HATCH_DIRECTION.fill ?? -1),
side: THREE.DoubleSide,
polygonOffset: true,
polygonOffsetFactor: POLYGON_OFFSET_FACTOR,
polygonOffsetUnits: POLYGON_OFFSET_FACTOR,
}),
);
mesh.name = `corridor-cut-wall:${wall.side}:${index}`;
group.add(mesh);
});
build.caps.forEach((cap, index) => {
const geometry = capGeometry(cap, origin);
if (!geometry) return;
+5 -1
View File
@@ -605,7 +605,11 @@ export function createRouteViewer(): RouteViewer {
outlineRows: build.outline.chainages.length,
outline: build.outline,
// 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다.
raw: { ribbons: build.ribbons, planCurves: build.planCurves ?? [] },
raw: {
ribbons: build.ribbons,
planCurves: build.planCurves ?? [],
cutWalls: build.cutWalls ?? [],
},
planCurves: (build.planCurves ?? []).map((curve) => ({
source: curve.source,
role: curve.role,