From 4a5a98fe4402c1e0e404f55c3a021560a3128758 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 27 Aug 2026 16:04:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=EC=84=B1=ED=86=A0=EB=A9=B4=EC=9D=84=20B06?= =?UTF-8?q?=20=EA=B5=AC=EA=B0=84=20=EB=B0=B0=EC=97=B4=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EB=90=98=EB=A9=94=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B06 횡단이 내는 변형 성토선을 3D 포켓에 되메운다. 다단 기슭막이면 횡단이 성토 > 구조물 > 성토 > 구조물 > 성토라 성토선이 하나가 아니다 — 합치지 않고 구간 1줄 = 리본 1장으로 낸다. - Station_Structure: StructureSilhouette.fillRuns 추가. 31점 폴리라인을 인덱스로 쪼개지 않고 B06 배열(outletFill/basinFill.segments, ford fillSegments, box fillLine)에서 바로 담는다. 벽 전면은 안 담는다 — 구조물 솔리드 몫. - Carve: 패치 리본을 실루엣당 1장이 아니라 run당 1장. 구간 배열을 쓸 때는 벽 트림을 걸지 않는다 — 바깥 끝 하나로 자르는 규칙이라 단 사이 구간이 아랫단 벽 바깥까지 통째로 잘려 사라졌다(실측: 0.86m 구간 2개 전멸). 배열이 비면 리본도 없다. - Build: FILL_PATCH_APPLY 신규(옛 CARVE_APPLY 대체). 투영 커브·절취를 다 낸 뒤 붙인다 — 먼저 넣으면 투영선이 패치에서 z를 읽어 절취 경계가 달라진다. - Viewer: snapCorridorEdges에서 패치 제외. B06 성토 구간은 벽 뒷면·구체 최상단에서 끝나는 게 정상인데 지형에 스냅해 4.1m 세로 지느러미가 생겼다(실측). - BUILD_VERSION 60 -> 63. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor.ts | 2 +- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 27 +-- B05_Profile/B05_Profile_UI_Corridor_Carve.ts | 186 ++++++++++-------- ...5_Profile_UI_Corridor_Station_Structure.ts | 62 +++++- B05_Profile/B05_Profile_UI_Viewer.ts | 4 + 5 files changed, 177 insertions(+), 104 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index a1dc32ea..551340a2 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -108,7 +108,7 @@ function fnv1a(text: string): string { // 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. // 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게 // 아니라 리본이 끝난 자리다(2026-08-27 사용자). -const BUILD_VERSION = 60; +const BUILD_VERSION = 63; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 6f677665..4a38d412 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -124,10 +124,15 @@ export interface CorridorBuildResult { } /** - * 절단 적용 여부(2026-08-25 사용자) — 지금은 **표기 모드**: 서피스를 자르지 않고 - * 원본 성토면 위에 절단 투영선만 그린다. 절단·패치로 되돌리려면 true. + * 구조물 구간 **성토 패치 리본** 적용 여부(2026-08-27 사용자). B06 횡단이 내는 + * 변형 성토선을 3D 포켓에 되메운다 — 다단이면 `성토 > 구조물 > 성토`라 성토선이 + * 하나가 아니므로 **구간 1줄 = 리본 1장**으로 낸다(`_Carve.ts` `fillRuns`). + * + * 절취(`FILL_CUT_APPLY`)와 별개다: 패치는 잘린 자리를 채우기만 하고, 절취·측벽· + * 경계 트림(0-2~0-6)은 그대로 둔다. 패치 리본은 절취 대상이 아니다 + * (`_Cut.ts` `ribbon.patch` 제외). */ -const CARVE_APPLY: boolean = false; +const FILL_PATCH_APPLY: boolean = true; /** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분). * 평면 궤적을 스플라인으로 잇게 되어(2026-08-23) 0.1m까지 쪼갤 이유가 없어졌다 — @@ -648,14 +653,6 @@ export function buildCorridor( }); if (points.length >= 2) caps.push({ points }); }); - // 패치 리본은 표기 모드에서 끈다(2026-08-25 사용자: 절단하지 말고 투영선만) — - // 원본 성토면이 그대로 있어 패치를 겹치면 z-fight만 난다. - if (CARVE_APPLY) { - ribbons.push( - ...buildStructurePatchRibbons(crossSections, structures, frameAt, profileZ ?? null), - ); - } - // 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌 // 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다. // 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다. @@ -668,8 +665,14 @@ export function buildCorridor( // 되어 비스듬한 커브에서 어긋난다(2026-08-26 사용자 지적, `_Cut.ts` 머리말). // 잘린 자리 윤곽(`cut-merged`)은 새 데이텀에 얹어 기존 커브 **뒤에 덧붙인다**. const cut = FILL_CUT_APPLY ? maskFillByPlanCurves(ribbons, planCurves) : null; + + // 성토 패치는 **투영 커브·절취를 다 낸 뒤** 붙인다 — 먼저 넣으면 투영선이 패치 + // 표면에서 z를 읽어 절취 경계가 달라지고, 절취가 패치를 다시 갉는다. + const patchRibbons = FILL_PATCH_APPLY + ? buildStructurePatchRibbons(crossSections, structures, frameAt, profileZ ?? null) + : []; return { - ribbons: cut ? cut.ribbons : ribbons, + ribbons: [...(cut ? cut.ribbons : ribbons), ...patchRibbons], outline, caps, structures, diff --git a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts index 6d852007..416f8566 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts @@ -30,6 +30,7 @@ import { PIECE_COLS, resample, type CorridorKind, + type OffsetPoint, } from "./B05_Profile_UI_Corridor_Station"; import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; @@ -234,97 +235,118 @@ export function buildStructurePatchRibbons( for (const section of crossSections) { const design = section.design; for (const silhouette of structureSilhouettes(section)) { - // 패치 전용 점(세월교 성토부선)이 있으면 그걸 쓴다 — 벽 전면·유로를 리본으로 - // 덮으면 관 구멍·L자 통로가 막힌다(2026-08-25 사용자 ②). - let source = silhouette.patchPoints ?? silhouette.points; // 노견 확장점 안쪽(수평 구간)은 노견 조각이 가져갔다 — 패치는 그 바깥부터. // 단 그 측에 측구가 있으면 노견을 안 넓혔으므로 스트립도 하지 않는다 — 하면 // 패치가 노견에서 떨어져 뜬다(2026-08-25 사용자 보고). const ditch = design?.ditch; const ditchEnabled = design?.ditch_enabled ?? (ditch != null && ditch.type !== "none"); const ditchOnSide = ditchEnabled && design?.ditch_side === silhouette.side; - const widen = silhouette.widenTo; - if (widen && !silhouette.patchPoints && !ditchOnSide) { - const sign = silhouette.side === "left" ? 1 : -1; - source = source.filter((point) => (point.offset_m - widen.offset_m) * sign > -1e-9); - if (source.length && Math.abs(source[0].offset_m - widen.offset_m) > 1e-9) { - source = [widen, ...source]; - } - } - if (source.length < 2) continue; - // "auto" = 실루엣 가운데 표고가 지반보다 높으면 성토, 낮으면 절토. - let kind: CorridorKind = silhouette.kind === "auto" ? "fill" : silhouette.kind; - if (silhouette.kind === "auto") { - const groundAt = groundInterpolator(section.samples); - const mid = silhouette.points[Math.floor(silhouette.points.length / 2)]; - if (groundAt && mid.elevation_m < groundAt(mid.offset_m) - 0.05) kind = "cut"; - } - const colCount = PIECE_COLS[kind]; - const from = section.chainage_m - silhouette.span.beforeM; - const to = section.chainage_m + silhouette.span.afterM; - if (!(to - from > 1e-6)) continue; - const rowChainages: number[] = []; - for (let at = from; at < to; at += 1) rowChainages.push(at); - rowChainages.push(to); - const baseZ = profileZ?.(section.chainage_m) ?? null; - - // 행 버퍼 — 벽·날개벽에 통째로 덮인 행에서 끊어 별개 리본으로 내보낸다. - let buffer: Array<{ chainage: number; frame: RouteFrame; points: typeof source }> = []; - const flush = (): void => { - if (buffer.length >= 2) { - const positions = new Float64Array(buffer.length * colCount * 3); - buffer.forEach((row, index) => { - const shift = - baseZ != null && row.frame.designZ != null ? row.frame.designZ - baseZ : 0; - resample(row.points, colCount).forEach((point, col) => { - const i = (index * colCount + col) * 3; - positions[i] = row.frame.cx + row.frame.leftX * point.offset_m; - positions[i + 1] = row.frame.cy + row.frame.leftY * point.offset_m; - positions[i + 2] = point.elevation_m + shift; - }); - }); - ribbons.push({ - kind, - side: silhouette.side, - colCount, - chainages: buffer.map((row) => row.chainage), - positions, - patch: true, - }); - } - buffer = []; - }; - - for (const at of rowChainages) { - const frame = frameAt(at); - if (!frame) { - flush(); - continue; - } - let points = source; - if (!revets.isEmpty) { - const outerCov = revets.outerAt( - frame.cx, - frame.cy, - frame.leftX, - frame.leftY, - silhouette.side, - points[0].offset_m, - points[points.length - 1].offset_m, - ); - if (outerCov !== null) { - const cut = cutInnerTo(points, outerCov, silhouette.side); - if (!cut) { - // 이 행은 벽·날개벽이 통째로 덮는다 — 리본을 끊는다(분리 서피스). - flush(); - continue; - } - points = cut; + const sign = silhouette.side === "left" ? 1 : -1; + // B06이 낸 성토 구간 배열을 그대로 쓴다 — **구간 1줄 = 리본 1장**이라 다단에서도 + // 줄이 안 섞이고 역주행으로 접히지도 않는다(2026-08-27 사용자). 배열이 없으면 + // 옛 동작 한 줄: 패치 전용 점(세월교 성토부선)이 있으면 그걸 쓴다 — 벽 전면·유로를 + // 리본으로 덮으면 관 구멍·L자 통로가 막힌다(2026-08-25 사용자 ②). + // 배열이 **비어 있으면** 그 측엔 성토 구간이 없다는 뜻이라 리본도 없다 — + // 옛 한 줄로 되돌리면 축퇴 실루엣(같은 점 2개)이 폭 0 리본을 만든다. + const runs: OffsetPoint[][] = silhouette.fillRuns ?? [ + silhouette.patchPoints ?? silhouette.points, + ]; + for (let source of runs) { + // 노견에서 시작하는 줄만 확장점으로 다듬는다 — 다단 성토 구간처럼 이미 바깥에서 + // 시작하는 줄에 확장점을 앞에 붙이면 노견에 없는 판이 하나 생긴다(2026-08-27). + const widen = silhouette.widenTo; + if ( + widen && + !silhouette.patchPoints && + !ditchOnSide && + source.length > 0 && + (source[0].offset_m - widen.offset_m) * sign <= 1e-9 + ) { + source = source.filter((point) => (point.offset_m - widen.offset_m) * sign > -1e-9); + if (source.length && Math.abs(source[0].offset_m - widen.offset_m) > 1e-9) { + source = [widen, ...source]; } } - buffer.push({ chainage: at, frame, points }); + if (source.length < 2) continue; + // "auto" = 실루엣 가운데 표고가 지반보다 높으면 성토, 낮으면 절토. + let kind: CorridorKind = silhouette.kind === "auto" ? "fill" : silhouette.kind; + if (silhouette.kind === "auto") { + const groundAt = groundInterpolator(section.samples); + const mid = silhouette.points[Math.floor(silhouette.points.length / 2)]; + if (groundAt && mid.elevation_m < groundAt(mid.offset_m) - 0.05) kind = "cut"; + } + const colCount = PIECE_COLS[kind]; + const from = section.chainage_m - silhouette.span.beforeM; + const to = section.chainage_m + silhouette.span.afterM; + if (!(to - from > 1e-6)) continue; + const rowChainages: number[] = []; + for (let at = from; at < to; at += 1) rowChainages.push(at); + rowChainages.push(to); + const baseZ = profileZ?.(section.chainage_m) ?? null; + + // 행 버퍼 — 벽·날개벽에 통째로 덮인 행에서 끊어 별개 리본으로 내보낸다. + let buffer: Array<{ chainage: number; frame: RouteFrame; points: typeof source }> = []; + const flush = (): void => { + if (buffer.length >= 2) { + const positions = new Float64Array(buffer.length * colCount * 3); + buffer.forEach((row, index) => { + const shift = + baseZ != null && row.frame.designZ != null ? row.frame.designZ - baseZ : 0; + resample(row.points, colCount).forEach((point, col) => { + const i = (index * colCount + col) * 3; + positions[i] = row.frame.cx + row.frame.leftX * point.offset_m; + positions[i + 1] = row.frame.cy + row.frame.leftY * point.offset_m; + positions[i + 2] = point.elevation_m + shift; + }); + }); + ribbons.push({ + kind, + side: silhouette.side, + colCount, + chainages: buffer.map((row) => row.chainage), + positions, + patch: true, + }); + } + buffer = []; + }; + + for (const at of rowChainages) { + const frame = frameAt(at); + if (!frame) { + flush(); + continue; + } + let points = source; + // 벽 트림은 **옛 한 줄** 전용이다. 한 줄은 구조물을 가로질러 그어져 벽이 덮은 + // 안쪽을 잘라내야 했다. B06 구간 배열은 이미 벽 **사이**에 정의돼 있어 겹치는 + // 건 아랫단 벽 뒷면 0.15m 남짓뿐이고 그것도 불투명한 벽 솔리드 안에 묻힌다. + // 반대로 트림을 걸면 바깥 끝 하나로 자르는 규칙이라 단 사이 구간이 아랫단 벽 + // 바깥까지 통째로 잘려 사라진다(2026-08-27 실측: 0.86m 구간이 벽 행에서 전멸). + if (!revets.isEmpty && !silhouette.fillRuns) { + const outerCov = revets.outerAt( + frame.cx, + frame.cy, + frame.leftX, + frame.leftY, + silhouette.side, + points[0].offset_m, + points[points.length - 1].offset_m, + ); + if (outerCov !== null) { + const cut = cutInnerTo(points, outerCov, silhouette.side); + if (!cut) { + // 이 행은 벽·날개벽이 통째로 덮는다 — 리본을 끊는다(분리 서피스). + flush(); + continue; + } + points = cut; + } + } + buffer.push({ chainage: at, frame, points }); + } + flush(); } - flush(); } } return ribbons; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts index a39115df..a72c98c3 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts @@ -49,6 +49,17 @@ export interface StructureSilhouette { * 관 구멍·유로가 막힌다). */ patchPoints?: OffsetPoint[]; + /** + * B06이 낸 **성토 구간 배열**(2026-08-27 사용자). 다단 기슭막이면 횡단이 + * `성토 > 구조물 > 성토 > 구조물 > 성토`라 성토선이 하나가 아니다 — 합치지 않고 + * 그대로 담아 패치 리본이 **구간 1줄 = 서피스 1장**으로 낸다. 벽 전면은 넣지 + * 않는다(구조물 솔리드 몫). 없으면 옛 동작(`patchPoints ?? points` 한 줄). + * + * 합치면 안 되는 이유: 다음 단 성토 시작점이 윗단 벽 발끝보다 **안쪽**이라 + * (윗단 벽 하단 +0.5m와 전면 경사선 1:0.3의 교차점) 편거리가 역주행한다 — + * 한 줄 리본으로는 스스로 접힌다. + */ + fillRuns?: OffsetPoint[][]; } /** 실루엣에서 노견 확장점 찾기 — 첫 점(노견) 표고 그대로 바깥으로 나간 가장 먼 점. */ @@ -102,6 +113,8 @@ export function boxSilhouettes(section: CrossSection): StructureSilhouette[] { kind, keepOrder: true, widenTo: widenToOf(points, side.role), + // 구체 최상단에서 끝나는 성토선 한 줄 — BOX는 다단이 없다. + fillRuns: [points], }, ]; }); @@ -141,16 +154,22 @@ export function fordSilhouettes(section: CrossSection): StructureSilhouette[] { // 리본으로 덮으면 관 구멍과 L자 통로가 막힌다(2026-08-25 사용자 ②). 벽·바닥은 // 구조물 솔리드가 그린다. const patchPoints: OffsetPoint[] = []; + // 성토 구간은 **구간별로도** 담는다 — 패치 리본이 한 장씩 낸다(2026-08-27 사용자). + const fillRuns: OffsetPoint[][] = []; // 유출측 다단과 달리 세월교는 단 나눔이 없다 — 접지 후 절토선(kind="cut")은 뺀다. const fill = side.fillSegments.filter((segment) => segment.kind !== "cut"); let kind: "cut" | "fill" = "fill"; if (fill.length) { - fill.forEach((segment) => + fill.forEach((segment) => { + const run: OffsetPoint[] = []; segment.points.forEach((point) => { - points.push({ offset_m: point.offset, elevation_m: point.elevation }); - patchPoints.push({ offset_m: point.offset, elevation_m: point.elevation }); - }), - ); + const at = { offset_m: point.offset, elevation_m: point.elevation }; + points.push(at); + patchPoints.push(at); + run.push(at); + }); + if (run.length >= 2) fillRuns.push(run); + }); } else if (side.cutLine) { // 바닥판이 원지반 안으로 박혔다 — 그만큼 원지반을 걷어낸다. const floorOuter = { @@ -159,10 +178,14 @@ export function fordSilhouettes(section: CrossSection): StructureSilhouette[] { }; points.push(floorOuter); patchPoints.push(floorOuter); + const run: OffsetPoint[] = [floorOuter]; for (const cutPoint of [side.cutLine.from, side.cutLine.to]) { - points.push({ offset_m: cutPoint.offset, elevation_m: cutPoint.elevation }); - patchPoints.push({ offset_m: cutPoint.offset, elevation_m: cutPoint.elevation }); + const at = { offset_m: cutPoint.offset, elevation_m: cutPoint.elevation }; + points.push(at); + patchPoints.push(at); + run.push(at); } + fillRuns.push(run); kind = "cut"; } else { points.push({ @@ -179,6 +202,7 @@ export function fordSilhouettes(section: CrossSection): StructureSilhouette[] { keepOrder: true, widenTo: widenToOf(points, onLeft ? "left" : "right"), patchPoints, + fillRuns, }, ]; }); @@ -212,6 +236,18 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] ); collected.set(side, bucket); }; + // 성토 구간은 **구간별로도** 담는다 — 다단이면 벽이 사이를 끊어 줄이 여러 개다 + // (2026-08-27 사용자). 벽 전면은 담지 않는다 — 구조물 솔리드가 그린다. + const runs = new Map<"left" | "right", OffsetPoint[][]>(); + const addRun = ( + side: "left" | "right", + points: ReadonlyArray<{ offset: number; elevation: number }>, + ): void => { + if (points.length < 2) return; + const bucket = runs.get(side) ?? []; + bucket.push(points.map((point) => ({ offset_m: point.offset, elevation_m: point.elevation }))); + runs.set(side, bucket); + }; const trimSlopeOf = (side: "left" | "right") => side === "left" ? layout.designTrim?.maxSlope : layout.designTrim?.minSlope; @@ -227,11 +263,17 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] const stepCount = Math.max(outletSegments.length, layout.extraWalls.length); for (let step = 0; step < stepCount; step += 1) { const segment = outletSegments[step]; - if (segment) push(outletSide, segment.points); + if (segment) { + push(outletSide, segment.points); + addRun(outletSide, segment.points); + } const wall = layout.extraWalls[step]; if (wall) push(outletSide, [wall.topJoint, wall.bottomFront]); } - layout.basinFill.segments.forEach((segment) => push(inletSide, segment.points)); + layout.basinFill.segments.forEach((segment) => { + push(inletSide, segment.points); + addRun(inletSide, segment.points); + }); layout.basinExtras.forEach((wall) => push(inletSide, [wall.topJoint, wall.bottomFront])); let basinCut = false; @@ -253,6 +295,7 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] // 집수정이 원지반에 박히면 절토선까지 — 그만큼 원지반을 걷어낸다. if (layout.basin.cutLine) { push(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]); + addRun(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]); basinCut = true; } spans[inletSide] = basinSpanOf(section); @@ -271,6 +314,7 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] // 유출측(성토 + 다단)은 수집 순서 그대로, 유입측(집수정·절토)은 종전 정렬. keepOrder: side === outletSide, widenTo: widenToOf(points, side), + fillRuns: runs.get(side) ?? [], }); }); return result; diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index e44b1ff8..1e48219d 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -536,6 +536,10 @@ export function createRouteViewer(): RouteViewer { // 측구는 제외 — 바깥 상단은 노견과 같은 Z가 규칙(2026-08-23 사용자 지시). // 지형 스냅하면 측구 바깥벽이 원지반까지 끌려 올라가 U형이 깨진다. if (ribbon.kind !== "cut" && ribbon.kind !== "fill") return; + // 구조물 구간 패치도 제외(2026-08-27) — B06 성토 구간은 벽 뒷면이나 구체 + // 최상단에서 **끝나는 게 정상**이라 바깥 끝이 지반이 아니다. 스냅하면 그 + // 자리가 원지반까지 끌려 내려가 세로 지느러미가 생긴다(실측 4.1m). + if (ribbon.patch) return; const cols = ribbon.colCount; const outerCol = ribbon.side === "right" ? 0 : cols - 1; const innerCol = ribbon.side === "right" ? cols - 1 : 0;