fix(B05): 변형 실루엣 투영 커브를 행마다 지반선에 트림

실루엣은 소유 측점 단면 한 장이라 그대로 밀면 구간 내내 같은 폭이 됐다.
0.5m 행마다 종단경사만큼 올린 뒤 `trimSlopeToGround`로 그 행 지반과 만나는
자리까지 잘라 실제 발끝을 따라가게 한다. 표기 커브만 바뀐다 — 서피스 절단·
패치는 손대지 않았다(`CARVE_APPLY` 계속 false, 사용자 지시).

4+4.3 우측 실측: 바깥끝 offset −3.25 고정 → −2.96~−4.42(지형 따라), 행 11 → 21.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 16:24:56 +09:00
co-authored by Claude Opus 5
parent 5a3b2ebe39
commit 25ae04b2cd
+59 -21
View File
@@ -19,6 +19,8 @@ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { CorridorStructure, RouteFrame } from "./B05_Profile_UI_Corridor_Structures";
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
import { assembleStripLoops, CarveFootprint, ringEdges } from "./B05_Profile_UI_Corridor_Carve";
import { mixedGround, trimSlopeToGround } from "./B05_Profile_UI_Corridor_Station";
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";
@@ -40,7 +42,7 @@ export interface PlanCurve {
const PLANE_LIFT_M = 5;
/** 비탈 스트립 종방향 걸음(m). */
const STEP_M = 1.0;
const STEP_M = 0.5;
/** 안팎 폭이 이만큼은 돼야 커브를 만든다(m). */
const MIN_WIDTH_M = 0.05;
@@ -62,6 +64,30 @@ interface PlanFrame {
leftY: number;
}
/** 누가거리 → 그 행의 지반선. 인접 두 측점 지반을 섞는다(코리도 빌드와 같은 방식). */
function buildGroundAt(
crossSections: ReadonlyArray<CrossSection>,
): (chainageM: number) => ((offset: number) => number) | null {
const rows = crossSections
.map((section) => ({
chainage: section.chainage_m,
ground: groundInterpolator(section.samples),
}))
.sort((a, b) => a.chainage - b.chainage);
return (chainageM) => {
if (!rows.length) return null;
for (let i = 0; i < rows.length - 1; i += 1) {
const lo = rows[i];
const hi = rows[i + 1];
if (chainageM < lo.chainage || chainageM > hi.chainage) continue;
const span = hi.chainage - lo.chainage;
const t = span > 1e-6 ? (chainageM - lo.chainage) / span : 0;
return mixedGround(lo.ground, hi.ground, t);
}
return chainageM <= rows[0].chainage ? rows[0].ground : rows[rows.length - 1].ground;
};
}
function at(frame: PlanFrame, offset: number): PXY {
return { x: frame.cx + frame.leftX * offset, y: frame.cy + frame.leftY * offset };
}
@@ -165,6 +191,7 @@ export function buildPlanCurves(
outline: CorridorOutline,
): PlanCurve[] {
const curves: PlanCurve[] = [];
const groundAt = buildGroundAt(crossSections);
for (const section of crossSections) {
const set = structures.filter(
(structure) => Math.abs(structure.chainage_m - section.chainage_m) < 1e-6,
@@ -213,30 +240,41 @@ export function buildPlanCurves(
const side = silhouette.side;
const sign = side === "left" ? 1 : -1;
const roadEdge = roadEdgeOf(side);
// 변형 실루엣 — [노견 확장끝 → 실루엣 바깥끝].
// 변형 실루엣 — [노견 확장끝 → 실루엣이 그 행 지반과 만나는 자리].
// 실루엣은 소유 측점 단면 한 장이라 그대로 밀면 구간 내내 같은 폭이 된다
// (2026-08-26 사용자 ②) — 행마다 지반선에 트림해 실제 발끝을 따라가게 한다.
// 서피스는 여전히 안 건드린다 — 표기 커브만 바뀐다.
const inner = silhouette.widenTo?.offset_m ?? roadEdge;
let outer = inner;
for (const point of silhouette.points) {
if ((point.offset_m - outer) * sign > 0) outer = point.offset_m;
}
const from = section.chainage_m - silhouette.span.beforeM;
const to = section.chainage_m + silhouette.span.afterM;
if ((outer - inner) * sign > MIN_WIDTH_M) {
const steps: number[] = [];
for (let atM = from; atM < to; atM += STEP_M) steps.push(atM);
steps.push(to);
push(
"slope-deformed",
side,
stripLoops(
steps.map((atM) => {
const frame = frameOf(atM);
return [at(frame, inner), at(frame, outer)] as [PXY, PXY];
}),
planZ,
),
);
// "auto" = 실루엣 가운데가 지반보다 높으면 성토, 낮으면 절토(패치 리본과 같은 판정).
let kind: "cut" | "fill" = silhouette.kind === "auto" ? "fill" : silhouette.kind;
if (silhouette.kind === "auto") {
const stationGround = groundInterpolator(section.samples);
const mid = silhouette.points[Math.floor(silhouette.points.length / 2)];
if (stationGround && mid.elevation_m < stationGround(mid.offset_m) - 0.05) kind = "cut";
}
const baseZ = frameOf(section.chainage_m).designZ;
const steps: number[] = [];
for (let atM = from; atM < to; atM += STEP_M) steps.push(atM);
steps.push(to);
const deformedRows = steps.map((atM): [PXY, PXY] | null => {
const frame = frameOf(atM);
const ground = groundAt(atM);
if (!ground) return null;
// 종단 경사만큼 실루엣을 통째로 올리고 내린다 — 측점 단면은 그 측점 계획고 기준이다.
const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0;
const placed = silhouette.points.map((point) => ({
offset_m: point.offset_m,
elevation_m: point.elevation_m + shift,
}));
const trimmed = trimSlopeToGround(placed, kind, ground);
if (!trimmed) return null;
const outer = trimmed[trimmed.length - 1].offset_m;
if ((outer - inner) * sign <= MIN_WIDTH_M) return null;
return [at(frame, inner), at(frame, outer)];
});
push("slope-deformed", side, stripLoops(deformedRows, planZ));
// 원본 성토·절토선 — [노견 끝 → 코리도 바깥 윤곽(catch)]. 구조물이 없다고 본 면이다.
const rows: Array<[PXY, PXY] | null> = [];
outline.chainages.forEach((chainageM, index) => {