feat(B05): 탑뷰 스케치 파란 커브를 절·성토면에 투영해 빨간 선으로 얹는다

구조물 상단 +5m 평면에 눕혀 두던 파란 스케치(slope-original, 유입 실선·유출 파선)의
XY를 그대로 쓰고 z만 그 자리 절·성토면에서 읽어 빨간 투영선(slope-projected)으로
낸다. 스케치는 평면에 그대로 남는다. 투영선이 격자 모서리에 앉도록 구조물 점유
구간 경계를 코리도 행으로 끼워 넣는다.

- Plan: slope-projected 소스 추가. 리본을 받아 `측|누가거리` 색인을 만들고 그 행의
  열 폴리라인에서 z를 읽는다. 절토·성토 모두 대상. 밑에 면이 없으면 점을 버려 끊는다.
- Plan: 파란 스케치 안쪽 기준을 소유 측점 노견 편거리 → **그 행 절·성토면이 실제로
  시작하는 자리**로 바꿨다. 종전에는 노폭 연장·측구를 못 따라가 커브가 면 밖(도로 위)에
  얹혔다 — 275.71 좌측은 노견 4m 확장분만큼 안쪽이었다.
- Plan: 비탈 리본 열 순서가 좌우 반대(조각은 편거리 오름차순, +offset=좌측)라 우측에서
  안쪽 끝을 반대로 집었다. 색인 단계에서 안쪽→바깥으로 방향을 통일한다.
- Mesh: 커브를 점의 z로 그린다(평면 커브는 z에 planZ가 들어 있어 동작 불변).
  투영선 색 = 빨강 0xff3b30, 서피스에서 0.02m 띄운다.
- Build: 구조물 구간 [from, to]를 행 경계로 강제 삽입. 안쪽 블록 걸음은 상수로 뺐다
  (지금은 바깥과 같은 0.2m — 경계 행만 새로 생긴다).
- Corridor: 저장본은 입력 해시로만 무효화되어 기하 코드를 고쳐도 옛 저장본이 계속
  복원된다. ENVELOPE_VERSION을 올리고 그 함정을 주석으로 박았다.
- Viewer: 검증용 __corridorBuild 훅(기존 __corridorClip과 같은 용도).

검증(공용 브라우저 캐시 데이터 실측):
- 3D 회귀 없음 — 외곽선 행 1756→1768, 기존 1756행 좌표까지 전부 불변, 추가된 12행은
  구조물 구간 경계뿐(79.3·83.3·85.3·89.3·144.73·154.73·199.72·202.12·270.71·
  274.71·276.71·280.71). 측구·절토 리본 행 수 불변.
- 투영선 안쪽 끝 8벌 전부 서피스 시작점과 XY·Z 정확 일치(gap 0, dz 0).
- 바깥 끝은 XY 일치, z만 최대 1.01m 차 — 세월교 날개벽·다단 기슭막이 앞면처럼
  거의 수직인 면이라 한 XY에 z가 여럿인 구간이다. 규칙 확정은 후속.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 21:51:54 +09:00
co-authored by Claude Opus 5
parent d857d3d48a
commit 398b5bdffd
5 changed files with 282 additions and 11 deletions
+11 -2
View File
@@ -29,8 +29,17 @@ export interface CorridorStructureHook {
applies: (chainageStart: number, chainageEnd: number) => boolean;
}
/** 저장본 형식 버전 — 올리면 구 저장본을 버리고 한 번 다시 빌드한다. */
const ENVELOPE_VERSION = 2;
/**
* 저장본 형식 버전 — 올리면 구 저장본을 버리고 한 번 다시 빌드한다.
*
* ⚠ 저장본은 **입력 해시(측점·노선)로만** 무효화된다. 코리도 기하 코드를 고쳐도
* 입력이 그대로면 옛 저장본이 계속 복원되어 **수정이 화면에 전혀 안 나타난다**
* (2026-08-26 반나절 허비). 기하를 건드렸으면 이 번호를 반드시 함께 올릴 것.
*
* 3 = 탑뷰 투영선(slope-projected) + 구조물 구간 경계 행 + 파란 스케치 안쪽 기준을
* 절·성토면 시작점으로 정정(노견 확장·측구 반영) — 2026-08-26.
*/
const ENVELOPE_VERSION = 3;
interface CorridorEnvelope {
version: number;
+68 -3
View File
@@ -46,6 +46,7 @@ import {
} from "./B05_Profile_UI_Corridor_Structures";
export type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures";
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";
export type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
@@ -103,6 +104,68 @@ const CARVE_APPLY: boolean = false;
* 0.2m면 곡선이 충분히 매끄럽고 데이터·연산은 절반이다(사용자 제안). */
const SUBDIVIDE_STEP_M = 0.2;
/**
* 투영 커브 **안쪽 블록**의 종방향 걸음(m) — 2026-08-26 사용자 B안.
* 지금은 바깥과 같은 0.2m라 경계 행만 새로 끼워 넣는 셈이다. 더 촘촘한 A안(0.1m)으로
* 바꾸려면 이 값만 내리면 된다(사용자: "보고 판단, A로 바꿀 수도 있다").
*/
const STRUCTURE_SPAN_STEP_M = SUBDIVIDE_STEP_M;
/**
* 구조물 점유 구간의 시작·끝 누가거리 — 여기를 **행 경계로 강제**해야 탑뷰 투영
* 커브가 격자 모서리 위에 앉는다. 값은 `_Corridor_Plan.ts`의 비탈 커브 구간과 같은
* 출처(`structureSilhouettes`의 전/후 몫)를 쓴다 — 두 곳이 어긋나면 커브 끝이 다시
* 셀 중간에 뜬다.
*/
function structureSpanBounds(crossSections: ReadonlyArray<CrossSection>): Array<[number, number]> {
const spans: Array<[number, number]> = [];
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,
]);
}
}
return spans;
}
/**
* 한 측점 구간(s0~s1)의 행 자리(0~1). 등간격 걸음에 **구조물 구간 경계**를 끼워
* 넣고 정렬·중복 제거한다. 비-마지막 구간은 t=1을 빼는 현행 규칙을 그대로 지킨다
* (다음 구간의 t=0과 겹치면 두께 0 행이 생긴다).
*/
function rowFractions(
startChainageM: number,
spanM: number,
steps: number,
includeEnd: boolean,
spans: ReadonlyArray<[number, number]>,
): number[] {
const ts: number[] = [];
for (let k = 0; k <= (includeEnd ? steps : steps - 1); k += 1) ts.push(k / steps);
const endChainageM = startChainageM + spanM;
const insideStep = Math.max(0.01, STRUCTURE_SPAN_STEP_M);
const put = (chainageM: number): void => {
const t = (chainageM - startChainageM) / spanM;
if (t > 1e-9 && t < 1 - 1e-9) ts.push(t);
};
for (const [from, to] of spans) {
if (to <= startChainageM || from >= endChainageM) continue;
put(from);
put(to);
// 안쪽 블록만 자기 걸음으로 다시 나눈다. 바깥과 같은 걸음(B안)이면 아래 중복
// 제거가 전부 걸러 내 정점이 늘지 않는다 — 경계 행만 새로 생긴다.
if (insideStep < SUBDIVIDE_STEP_M - 1e-9) {
for (let at = from + insideStep; at < to - 1e-9; at += insideStep) put(at);
}
}
ts.sort((a, b) => a - b);
// 1mm보다 가까운 행은 합친다 — 겹친 행은 넓이 0 삼각형만 만든다.
const epsilon = spanM > 1e-9 ? 0.001 / spanM : 1;
return ts.filter((t, index) => index === 0 || t - ts[index - 1] > epsilon);
}
/** 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 보간한다. */
/** 종단 보정량 — 도로부(차도·노견·측구)는 계획고에 붙어 있으므로 전량 가산한다.
* 비탈은 상대 좌표라 행 앵커를 통해 보정이 실리고, 끝은 지반 트림이 정한다. */
@@ -221,6 +284,8 @@ export function buildCorridor(
};
};
const structures = buildCorridorStructures(crossSections, frameAt);
// 탑뷰 투영 커브가 격자 모서리에 앉도록 구조물 구간 경계를 행으로 끼워 넣는다.
const spanBounds = structureSpanBounds(crossSections);
for (let i = 0; i < stations.length - 1; i += 1) {
const s0 = stations[i];
@@ -235,8 +300,7 @@ export function buildCorridor(
const crossing = crossingAt?.(s0.chainage_m, s1.chainage_m) ?? null;
const tSwitch =
crossing === null ? 0.5 : Math.min(0.98, Math.max(0.02, (crossing - s0.chainage_m) / span));
for (let k = 0; k <= (last ? steps : steps - 1); k += 1) {
const t = k / steps;
for (const t of rowFractions(s0.chainage_m, span, steps, last, spanBounds)) {
const chainage = s0.chainage_m + span * t;
const center =
t === 0
@@ -545,6 +609,7 @@ export function buildCorridor(
structures,
// 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌
// 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다.
planCurves: buildPlanCurves(crossSections, structures, frameAt, outline),
// 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다.
planCurves: buildPlanCurves(crossSections, structures, frameAt, outline, ribbons),
};
}
+6 -2
View File
@@ -42,6 +42,8 @@ const PLAN_CURVE_COLORS: Record<PlanCurve["source"], number> = {
"slope-original": 0x38bdf8,
// 날개벽 성토끝 박스 — 구조물 노랑과 구분되게 주황(2026-08-26 사용자).
"wing-box": 0xff8c1a,
// 스케치 파란 커브를 절·성토면에 얹은 투영선 — 빨강(2026-08-26 사용자 지정).
"slope-projected": 0xff3b30,
};
/** 유출측 파선 눈금(m) — 유입은 실선이다. */
@@ -369,8 +371,10 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou
curve.loops.forEach((loop, index) => {
if (loop.length < 2) return;
const positions: number[] = [];
for (const [x, y] of loop) {
positions.push(x - origin.cx, curve.planZ - origin.cz, -(y - origin.cy));
// 점의 z를 그대로 쓴다 — 평면 커브는 z에 planZ가 들어 있어 동작이 같고,
// 투영선(`slope-projected`)만 점마다 절·성토면 표고를 들고 온다(2026-08-26).
for (const [x, y, z] of loop) {
positions.push(x - origin.cx, z - origin.cz, -(y - origin.cy));
}
const geometry = new THREE.BufferGeometry().setAttribute(
"position",
+169 -4
View File
@@ -28,7 +28,14 @@ import { mixedGround, trimSlopeToGround } from "./B05_Profile_UI_Corridor_Statio
import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve";
import type { PXY } from "./B05_Profile_UI_Corridor_Union";
export type PlanCurveSource = "structure" | "slope-deformed" | "slope-original" | "wing-box";
export type PlanCurveSource =
| "structure"
| "slope-deformed"
| "slope-original"
| "wing-box"
/** 파란 스케치 커브(`slope-original`)를 절·성토면에 탑뷰 투영한 **빨간 선**
* (2026-08-26 사용자). 스케치는 평면에 그대로 남고 이 커브만 서피스에 얹힌다. */
| "slope-projected";
/** 투영 커브 한 벌 — 한 구조물 세트의 한 측(유입 또는 유출), 한 종류. */
export interface PlanCurve {
@@ -162,6 +169,137 @@ function apart(a: PXY, b: PXY): boolean {
return Math.hypot(b.x - a.x, b.y - a.y) > MIN_WIDTH_M;
}
/** 절·성토면 조회 묶음 — 표고와 **면이 시작하는 안쪽 끝**을 같은 색인에서 낸다. */
interface SurfaceLookup {
/** 그 행·그 측 서피스의 z. 밑에 면이 없으면 null. */
zAt: (chainageM: number, side: Side, x: number, y: number) => number | null;
/**
* 그 행·그 측 서피스가 **실제로 시작하는 안쪽 끝**(노견 확장·측구를 이미 반영한
* 자리). 파란 스케치의 안쪽 기준을 여기에 맞춰야 커브가 면 경계와 겹친다
* (2026-08-26 사용자 지적: 노견 확장 개념 미반영).
*/
innerAt: (chainageM: number, side: Side, nearX: number, nearY: number) => PXY | null;
}
function buildSurfaceZAt(ribbons: ReadonlyArray<CorridorRibbonLike>): SurfaceLookup {
/**
* `측(side)|누가거리` → 그 행의 [x, y, z] 열 목록. 절·성토만 담고, 언제나
* **안쪽(도로) → 바깥(끝선)** 순으로 뒤집어 담는다.
*
* 비탈 조각은 편거리 오름차순으로 저장된다. 좌표 규약이 `+offset = 좌측`이라
* 좌측은 안쪽→바깥이지만 **우측은 바깥→안쪽**이 된다(2026-08-26 실측, 세월교
* 149.73 우측: 0열이 차도 끝에서 5.97m, 마지막 열이 0m). 방향을 여기서 한 번
* 맞춰 두지 않으면 안쪽 끝 조회도, 수직면 무승부 처리도 우측에서 뒤집힌다.
*/
const rowsAt = new Map<string, Array<Array<[number, number, number]>>>();
for (const ribbon of ribbons) {
if (ribbon.kind !== "cut" && ribbon.kind !== "fill") continue;
ribbon.chainages.forEach((chainageM, row) => {
const cols: Array<[number, number, number]> = [];
for (let col = 0; col < ribbon.colCount; col += 1) {
const i = (row * ribbon.colCount + col) * 3;
cols.push([ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]]);
}
if (ribbon.side === "right") cols.reverse();
const key = `${ribbon.side}|${chainageM}`;
const bucket = rowsAt.get(key);
if (bucket) bucket.push(cols);
else rowsAt.set(key, [cols]);
});
}
/**
* 폴리라인 위 XY 최근접점의 z. 폴리라인 밖이면 양 끝으로 클램프된다.
*
* 단면에는 **거의 수직인 면**(다단 기슭막이 벽 앞면)이 있어 같은 XY에 z가 여러 개
* 걸린다 — 탑뷰 투영이 원래 모호한 자리다. 거리가 같으면 **바깥 열이 이기게** 한다:
* 열은 안쪽→바깥 순이고, 커브 바깥 끝이 뜻하는 자리는 벽 상단이 아니라 **성토 끝선**
* 이다(2026-08-26 실측: 275.71 좌측에서 끝선이 0.69m 떠 있었다).
*/
const zOnRow = (
cols: Array<[number, number, number]>,
x: number,
y: number,
): { distance: number; z: number } | null => {
let best: { distance: number; z: number } | null = null;
for (let i = 1; i < cols.length; i += 1) {
const [ax, ay, az] = cols[i - 1];
const [bx, by, bz] = cols[i];
const dx = bx - ax;
const dy = by - ay;
const lengthSq = dx * dx + dy * dy;
const t =
lengthSq > 1e-12 ? Math.min(1, Math.max(0, ((x - ax) * dx + (y - ay) * dy) / lengthSq)) : 0;
const px = ax + dx * t;
const py = ay + dy * t;
const distance = Math.hypot(x - px, y - py);
const z = az + (bz - az) * t;
if (!best || distance < best.distance - 1e-6) best = { distance, z };
// 무승부(수직면) — 뒤에 오는 바깥 열로 갈아탄다. 최소 거리는 유지한다.
else if (distance < best.distance + 1e-6)
best = { distance: Math.min(distance, best.distance), z };
}
return best;
};
return {
zAt: (chainageM, side, x, y) => {
const buckets = rowsAt.get(`${side}|${chainageM}`);
if (!buckets) return null;
let best: { distance: number; z: number } | null = null;
for (const cols of buckets) {
// 정점에 얹힌 XY(면 안쪽 끝·바깥 끝)는 **그 정점 z를 그대로** 쓴다 — 보간·
// 최근접 판정을 태우면 수직면(다단 벽 앞면)에서 엉뚱한 단이 이긴다. 허용오차는
// 1mm — 1e-6으로 잡았더니 끝점이 0.5mm 어긋난 행에서 최대 1.01m 튀었다
// (2026-08-26 실측, 세월교 149.73). 열은 안쪽→바깥이라 무승부면 바깥이 이긴다.
let exact: { distance: number; z: number } | null = null;
for (const [px, py, pz] of cols) {
const distance = Math.hypot(x - px, y - py);
if (distance > VERTEX_SNAP_M) continue;
if (!exact || distance < exact.distance + 1e-9) exact = { distance, z: pz };
}
const hit = exact ?? zOnRow(cols, x, y);
if (!hit) continue;
if (!best || hit.distance < best.distance) best = hit;
}
return best ? best.z : null;
},
innerAt: (chainageM, side, nearX, nearY) => {
const buckets = rowsAt.get(`${side}|${chainageM}`);
if (!buckets) return null;
// 한 행에 절·성토가 같이 있으면 **도로에 가까운 쪽**의 안쪽 끝을 쓴다.
let best: { distance: number; point: PXY } | null = null;
for (const cols of buckets) {
const [x, y] = cols[0];
const distance = Math.hypot(nearX - x, nearY - y);
if (!best || distance < best.distance) best = { distance, point: { x, y } };
}
return best ? best.point : null;
},
};
}
/** 표고 조회에 필요한 리본 최소 형태 — Build의 `CorridorRibbon`이 그대로 들어맞는다. */
export interface CorridorRibbonLike {
kind: string;
/** "left" | "right" | "center" — 비탈 리본은 좌·우뿐이라 center는 걸러진다. */
side: string;
colCount: number;
chainages: ReadonlyArray<number>;
positions: ArrayLike<number>;
}
/** 투영 점을 리본 정점으로 붙여 보는 허용오차(m) — 수직면에서 단이 뒤바뀌는 것 방지. */
const VERTEX_SNAP_M = 0.001;
/** 투영선을 서피스에서 띄우는 높이(m) — z-fighting 방지. */
const PROJECT_LIFT_M = 0.02;
/** 스트립 행을 **점마다 다른 표고**로 얹어 닫힌 고리로 만든다(투영선용). */
function projectedLoops(
rows: ReadonlyArray<[[number, number, number], [number, number, number]] | null>,
): Array<Array<[number, number, number]>> {
return assembleStripLoops(rows);
}
/**
* 구조물 세트별 탑뷰 투영 커브. 세트 = 같은 측점이 소유한 구조물 묶음
* (기슭막이 + 집수정 + 배관, 또는 BOX암거·세월교 한 벌).
@@ -171,9 +309,12 @@ export function buildPlanCurves(
structures: ReadonlyArray<CorridorStructure>,
frameAt: (chainageM: number) => RouteFrame | null,
outline: CorridorOutline,
/** 절·성토 리본 — 투영선 표고를 여기서 읽는다. 없으면 투영선을 안 낸다. */
ribbons: ReadonlyArray<CorridorRibbonLike> = [],
): PlanCurve[] {
const curves: PlanCurve[] = [];
const groundAt = buildGroundAt(crossSections);
const surface = buildSurfaceZAt(ribbons);
for (const section of crossSections) {
const set = structures.filter(
(structure) => Math.abs(structure.chainage_m - section.chainage_m) < 1e-6,
@@ -280,21 +421,28 @@ export function buildPlanCurves(
return [at(frame, inner), at(frame, outer)];
});
push("slope-deformed", side, stripLoops(deformedRows, planZ));
// 원본 성토·절토선 — [노견 끝 → 코리도 바깥 윤곽(catch)]. 구조물이 없다고 본 면이다.
// 원본 성토·절토선 — [면 안쪽 끝 → 코리도 바깥 윤곽(catch)]. 구조물이 없다고 본 면이다.
// 안쪽 끝은 **그 행 절·성토면이 실제로 시작하는 자리**를 쓴다 — 소유 측점 노견
// 편거리를 그대로 쓰면 노폭 연장·측구를 못 따라가 커브가 면 밖(도로 위)에 얹힌다
// (2026-08-26 사용자 지적. 275.71 좌측 실측: 노견 확장 4m만큼 안쪽으로 들어가 있었다).
const rows: Array<[PXY, PXY] | null> = [];
// BOX암거는 가로 방향이 고정이라 그 행의 윤곽 점을 그대로 쓰면 방향이 틀어진다.
// 대신 고정 방향으로 쏜 선이 성토 끝선과 만나는 자리를 바깥 끝으로 삼는다 —
// 끝은 여전히 실제 성토 끝선 위다(2026-08-26 사용자).
const boxToe = boxAxis ? nearToe(outline, side, section.chainage_m) : 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 innerXY = at(frame, roadEdge);
const edgeXY = at(frame, roadEdge);
const surfaceStart = 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);
if (!hit) {
rows.push(null);
projected.push(null);
return;
}
outerXY = { x: hit.x, y: hit.y };
@@ -302,9 +450,26 @@ export function buildPlanCurves(
const [ox, oy] = side === "left" ? outline.left[index] : outline.right[index];
outerXY = { x: ox, y: oy };
}
rows.push(apart(innerXY, outerXY) ? [innerXY, outerXY] : null);
// 면 시작점이 바깥 끝과 붙어 버리는 행(그 측 비탈이 거의 없는 자리)에서는
// 노견 편거리로 되돌린다 — 안 그러면 그 측 커브가 통째로 사라진다.
const innerXY = surfaceStart && apart(surfaceStart, outerXY) ? surfaceStart : edgeXY;
const keep = apart(innerXY, outerXY);
rows.push(keep ? [innerXY, outerXY] : null);
// 서피스 표고를 못 읽는 자리(밑에 절·성토 리본이 없는 행)는 선을 끊는다 —
// 없는 면에 억지로 붙이지 않는다.
const innerZ = keep ? surface.zAt(chainageM, side, innerXY.x, innerXY.y) : null;
const outerZ = keep ? surface.zAt(chainageM, side, outerXY.x, outerXY.y) : null;
projected.push(
innerZ === null || outerZ === null
? null
: [
[innerXY.x, innerXY.y, innerZ + PROJECT_LIFT_M],
[outerXY.x, outerXY.y, outerZ + PROJECT_LIFT_M],
],
);
});
push("slope-original", side, stripLoops(rows, planZ));
push("slope-projected", side, projectedLoops(projected));
}
}
return curves;
+28
View File
@@ -590,6 +590,34 @@ export function createRouteViewer(): RouteViewer {
function setCorridor(build: CorridorBuildResult | null): void {
disposeCorridorGroup();
corridorBuild = build;
// 검증용 요약(2026-08-26) — 격자 재배치·투영선이 리본을 흔들었는지 화면 밖에서
// 수치로 확인한다(`__corridorClip`과 같은 용도).
(window as unknown as { __corridorBuild?: unknown }).__corridorBuild = build
? {
ribbons: build.ribbons.map((ribbon) => ({
kind: ribbon.kind,
side: ribbon.side,
rows: ribbon.chainages.length,
cols: ribbon.colCount,
first: ribbon.chainages[0],
last: ribbon.chainages[ribbon.chainages.length - 1],
})),
outlineRows: build.outline.chainages.length,
outline: build.outline,
// 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다.
raw: { ribbons: build.ribbons, planCurves: build.planCurves ?? [] },
planCurves: (build.planCurves ?? []).map((curve) => ({
source: curve.source,
role: curve.role,
side: curve.side,
at: curve.setChainageM,
loops: curve.loops.length,
points: curve.loops.reduce((sum, loop) => sum + loop.length, 0),
z0: curve.loops[0]?.[0]?.[2] ?? null,
planZ: curve.planZ,
})),
}
: null;
if (build && bounds) {
snapCorridorEdges(build);
corridorGroup = createCorridorGroup(build, bounds);