fix(B05): BOX암거 날개벽을 구체 모서리에 붙이고 에이프런을 평면 사다리꼴로

날개벽 시작점을 측점 중앙 프레임으로 잡아 곡선 구간에서 구체 끝과 0.15m 어긋났다.
그 모서리 링의 프레임으로 잡아 정확히 붙인다.

또 에이프런이 평면에서 직사각형이라 각도만큼 벌어진 날개벽 발치가 바닥 밖으로
나갔다. 날개벽 선을 따라 안쪽 변이 물러나는 **사다리꼴**로 바꿔 발치를 받는다
(2026-08-25 사용자 지적).

검증(10+0.9): 날개벽 4매 모두 구체 모서리와 거리 0.000m,
에이프런 링별 안쪽 변 [4.158, 2.744, 2.744, 4.158] — 바깥 링에서 폭 0으로 닫힌다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:41:31 +09:00
co-authored by Claude Opus 5
parent a87182496e
commit 6e87dd6cdf
@@ -109,6 +109,14 @@ export function boreRingChainages(
return [...values].sort((a, b) => a - b);
}
/** 그 측의 날개벽 제원 — 유입/유출은 상단측(uphill)이 어느 쪽이냐로 갈린다. */
function wingOf(section: CrossSection, role: "left" | "right") {
const box = section.box;
if (!box) return null;
const inletOnLeft = (section.uphill_side ?? "left") === "left";
return (role === "left") === inletOnLeft ? box.wing_in : box.wing_out;
}
/** 링 누가거리 목록 → 프레임(실패하면 측점 프레임으로 대체). */
function ringsAt(
chainages: number[],
@@ -189,14 +197,46 @@ export function boxSolids(
});
}
// 에이프런 2매 — 날개벽 구간 바닥, 각 끝 표고에서 수평.
// 에이프런 2매 — 날개벽 구간 바닥. 각 끝 표고에서 수평이고, **평면에서는 사다리꼴**
// 이다(2026-08-25 사용자) — 날개벽이 각도만큼 벌어지므로 바닥도 그만큼 넓어져야
// 날개벽 발치가 바닥에 얹힌다.
for (const side of layout.sides) {
if (!side.apron.length) continue;
const wing = wingOf(section, side.role);
const angle = ((wing?.angle_deg ?? 45) * Math.PI) / 180;
const length = Math.max(wing?.length_m ?? 0, 0);
const flare = length * Math.sin(angle);
const outward = side.role === "left" ? 1 : -1;
const innerX = side.apron[0].offset;
const outerX = side.apron[1].offset;
const top = side.apron[0].elevation;
const bottom = side.apron[3].elevation;
// |y| ≤ 구체 반폭이면 구체 끝면부터, 그 밖은 날개벽 선을 따라 안쪽 변이 물러난다.
const innerAt = (offsetFromCenter: number): number => {
const beyond = Math.abs(offsetFromCenter) - halfSpan;
if (beyond <= 0) return innerX;
const run = flare > 1e-6 ? (beyond / flare) * Math.abs(outerX - innerX) : 0;
return innerX + outward * Math.min(run, Math.abs(outerX - innerX));
};
const reach = halfSpan + flare;
const apronPlan = [-reach, -halfSpan, halfSpan, reach];
solids.push({
chainage_m: chainage,
kind: "basin",
polygons: repeat(side.apron, bodyRings.length),
rings: bodyRings,
polygons: apronPlan.map((at) => {
const inner = innerAt(at);
return [
[inner, top],
[outerX, top],
[outerX, bottom],
[inner, bottom],
] as SectionPolygon;
}),
rings: ringsAt(
apronPlan.map((at) => chainage + at),
frameAt,
stationFrame,
),
});
}
@@ -246,8 +286,11 @@ function wingSolids(
const height0 = side.topElevation - side.bottomElevation;
const height1 = Math.max(wing.height_m ?? height0, 0.3);
for (const along of [1, -1]) {
const startX = here.cx + here.leftX * side.offset + dirX * along * halfSpan;
const startY = here.cy + here.leftY * side.offset + dirY * along * halfSpan;
// 시작점은 **그 모서리 링의 프레임**으로 잡는다 — 중앙 프레임으로 잡으면 곡선
// 구간에서 구체 끝과 어긋난다(2026-08-25 사용자 지적).
const cornerFrame = frameAt(chainage + along * halfSpan) ?? here;
const startX = cornerFrame.cx + cornerFrame.leftX * side.offset;
const startY = cornerFrame.cy + cornerFrame.leftY * side.offset;
// 날개 방향 = 구체 축을 도로 방향으로 각도만큼 튼 단위벡터.
const wingX = here.leftX * axisSign * Math.cos(angle) + dirX * along * Math.sin(angle);
const wingY = here.leftY * axisSign * Math.cos(angle) + dirY * along * Math.sin(angle);