feat(B06): 관 접속 절·성토선 3도 물매 + 교차점 보간 + 절성토 교차 기준 자동 줌아웃

- 관 유도 접속선을 0도 → **3도**로: 유입측은 계류 쪽 오름(물이 관으로),
  유출측 매몰 절토선은 계류 쪽 내림(물이 밖으로). 집수정 I형 되메움선도 동일
- 모든 유도선·성토부선 끝점을 격자점이 아니라 **선형 보간 교차점**에 붙여
  선이 원지반을 미세하게 벗어나던 문제 해소(2026-08-22 사용자 지적)
- 카드 자동 줌아웃: **구조물 없는 상태**의 설계 절·성토선이 원지반과 만나는
  지점(+0.5m)이 표시 반폭 밖이면 그 카드만 반폭을 넓혀 그린다. 보유 샘플
  범위를 넘는 경우는 기존 전역 반폭 재생성 경로(applyPanelToAll)가 담당

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 15:44:10 +09:00
co-authored by Claude Opus 5
parent de78feabde
commit cd855e32d3
7 changed files with 154 additions and 38 deletions
+4 -4
View File
@@ -94,7 +94,7 @@ export function appendCulvertOverlay(
// 매몰 구간 — 0도 절토선을 원지반 교차까지(2026-08-22 사용자 ③).
planLine(
segment.points,
`유출부 0도 절토선 — 벽이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`,
`유출부 3도 절토선(계류 쪽 내림 — 물이 밖으로) — 벽이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`,
);
continue;
}
@@ -118,8 +118,8 @@ export function appendCulvertOverlay(
planLine(
layout.inletFill.points,
layout.inletFill.kind === "cut"
? `유입부 접속 절토선 — 관 하단에서 0도 1m 후 표준 절토 경사로 원지반까지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`
: `유입부 0도 성토선 — 관 하단에서 수평, 원지반 교차에서 정지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`,
? `유입부 접속 절토선 — 관 하단에서 3도(계류 쪽 오름) 1m 후 표준 절토 경사로 원지반까지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`
: `유입부 3도 성토선(계류 쪽 오름 — 물이 관으로) — 원지반 교차에서 정지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`,
);
}
};
@@ -265,7 +265,7 @@ export function appendCulvertOverlay(
const fillTitle = document.createElementNS(SVG_NS, "title");
fillTitle.textContent =
basin.shape === "I"
? "집수정(I형) 되메움(성토)선 — 관 하단에서 수평으로 원지반과 연결"
? "집수정(I형) 되메움(성토)선 — 관 하단에서 3도(계류 쪽 오름)로 원지반과 연결"
: `집수정(${shapeName}) 성토(되메움)선 — 구조물이 원지반 밖으로 나와 5° 경사로 채움`;
fill.append(fillTitle);
layer.append(fill);
@@ -13,6 +13,7 @@
* ========================================================================== */
import {
PIPE_CONNECT_GRADE,
BASIN_INNER_HEIGHT_M,
BASIN_MEMBER_THICKNESS_M,
REVET_FREEBOARD_M,
@@ -262,12 +263,26 @@ export function applyBasinPipeFill(
outward: number,
groundAt: (offset: number) => number,
): void {
// 되메움선 물매 3도(2026-08-22 사용자) — 유입측이라 계류 쪽이 높다(물이 관으로).
if (basin.shape !== "I" || corner.elevation <= groundAt(corner.offset) + 0.02) return;
let previousGap = corner.elevation - groundAt(corner.offset); // 선 지반
for (let run = 0.05; run <= 30; run += 0.05) {
const probe = corner.offset + outward * run;
if (groundAt(probe) >= corner.elevation) {
basin.fillLine = { from: corner, to: { offset: probe, elevation: corner.elevation } };
const elevation = corner.elevation + PIPE_CONNECT_GRADE * run;
const gap = elevation - groundAt(probe);
if (gap <= 0) {
// 끝점은 보간 교차점 — 격자점이면 지반을 미세하게 벗어난다(2026-08-22 사용자).
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const runCross = run - 0.05 * (1 - fraction);
basin.fillLine = {
from: corner,
to: {
offset: corner.offset + outward * runCross,
elevation: corner.elevation + PIPE_CONNECT_GRADE * runCross,
},
};
return;
}
previousGap = gap;
}
}
@@ -110,6 +110,10 @@ export function materialFromForm(form: string | null | undefined): RevetMaterial
return "dry";
}
/** 관 접속 절·성토선 물매 — 0도 대신 3도(2026-08-22 사용자). 방향은 물 흐름을
* 따른다: 유입측은 계류 쪽이 높고(물이 관으로), 유출측은 계류 쪽이 낮다(관에서 밖으로). */
export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180);
/** 배관 기슭막이 높이 조작 하한(m) — 2~3m 구간 지시(2026-08-22)의 하한. */
export const PIPE_WALL_MIN_HEIGHT_M = 2.0;
/** 배관 기슭막이 기본 높이(m) — 재질과 무관하게 2.0(2026-08-22 사용자 정정). */
@@ -13,6 +13,7 @@
import {
EXTRA_WALL_DEFAULT_HEIGHT_M,
PIPE_CONNECT_GRADE,
EXTRA_WALL_MIN_HEIGHT_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MIN,
@@ -56,16 +57,26 @@ export interface OutletExtrasResult {
/** 수평선(0도)이 계류측으로 나가며 원지반 아래로 다시 떨어지는 첫 지점(절토 교차).
* 없으면 null — 그 자리는 매몰 상태로 둘 수 없다(2026-08-22 사용자 ③ 이동 금지). */
export function horizontalCrossing(
export function slopedCrossing(
from: OffsetPoint,
outward: number,
grade: number,
groundAt: (offset: number) => number,
limitOffset: number,
): number | null {
const span = (limitOffset - from.offset) * outward;
for (let t = 0.25; t <= span + 1e-9; t += 0.25) {
const step = 0.25;
let previousGap = groundAt(from.offset) - from.elevation; // 지반 − 선 (양수 = 지반 위)
for (let t = step; t <= span + 1e-9; t += step) {
const x = from.offset + outward * t;
if (groundAt(x) <= from.elevation) return x;
const gap = groundAt(x) - (from.elevation + grade * t);
if (gap <= 0) {
// 격자 사이 정확 교차점을 선형 보간 — 격자점을 그대로 쓰면 선이 지반을
// 미세하게 벗어난다(2026-08-22 사용자 지적).
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
return x - outward * step * (1 - fraction);
}
previousGap = gap;
}
return null;
}
@@ -85,33 +96,58 @@ export function inletGroundConnector(
const ground = groundAt(corner.offset);
const span = (limitOffset - corner.offset) * outward;
if (corner.elevation > ground + 0.02) {
let cross = corner.offset + outward * Math.max(span, 0);
// 3도 성토선 — 계류 쪽이 높다(물이 관으로). 지반 교차점(보간)에서 정지.
let end: OffsetPoint = {
offset: corner.offset + outward * Math.max(span, 0),
elevation: corner.elevation + PIPE_CONNECT_GRADE * Math.max(span, 0),
};
let previousGap = corner.elevation - groundAt(corner.offset); // 선 지반
for (let t = 0.25; t <= span + 1e-9; t += 0.25) {
const x = corner.offset + outward * t;
if (groundAt(x) >= corner.elevation) {
cross = x;
const lineElevation = corner.elevation + PIPE_CONNECT_GRADE * t;
const gap = lineElevation - groundAt(x);
if (gap <= 0) {
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const tCross = t - 0.25 * (1 - fraction);
end = {
offset: corner.offset + outward * tCross,
elevation: corner.elevation + PIPE_CONNECT_GRADE * tCross,
};
break;
}
previousGap = gap;
}
return {
points: [corner, { offset: cross, elevation: corner.elevation }],
lengthM: Math.abs(cross - corner.offset),
points: [corner, end],
lengthM: Math.hypot(end.offset - corner.offset, end.elevation - corner.elevation),
overLimit: false,
kind: "fill",
};
}
if (corner.elevation < ground - 0.02) {
// 3도 직선 1m(계류 쪽이 높다) 후 표준 절토 경사.
const bend: OffsetPoint = {
offset: corner.offset + outward * 1.0,
elevation: corner.elevation,
elevation: corner.elevation + PIPE_CONNECT_GRADE * 1.0,
};
let last = bend;
const cutSpan = (limitOffset - bend.offset) * outward;
let previousGap = groundAt(bend.offset) - bend.elevation; // 지반 절토선
for (let t = 0.25; t <= Math.max(cutSpan, 0) + 1e-9; t += 0.25) {
const x = bend.offset + outward * t;
const elevation = bend.elevation + t / Math.max(cutSlopeRatio, 0.1);
const gap = groundAt(x) - elevation;
if (gap <= 0) {
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const tCross = t - 0.25 * (1 - fraction);
last = {
offset: bend.offset + outward * tCross,
elevation: bend.elevation + tCross / Math.max(cutSlopeRatio, 0.1),
};
break;
}
last = { offset: x, elevation };
if (elevation >= groundAt(x)) break;
previousGap = gap;
}
return {
points: [corner, bend, last],
@@ -138,15 +174,28 @@ function trailingFill(
let length = 0;
let previous = src;
const step = 0.25;
let previousGap = src.elevation - groundAt(src.offset); // 사면선 지반
for (let t = step; t <= 30 + 1e-9; t += step) {
const offset = src.offset + outward * t;
const slopeElevation = src.elevation - t / FILL_SLOPE_RATIO_MIN;
const groundElevation = groundAt(offset);
const point: OffsetPoint = { offset, elevation: Math.max(slopeElevation, groundElevation) };
const gap = slopeElevation - groundAt(offset);
if (gap <= 0) {
// 끝점은 격자 대신 **보간 교차점**에 붙인다(2026-08-22 사용자 — 미세 이탈 정정).
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const tCross = t - step * (1 - fraction);
const point: OffsetPoint = {
offset: src.offset + outward * tCross,
elevation: src.elevation - tCross / FILL_SLOPE_RATIO_MIN,
};
length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation);
points.push(point);
break;
}
const point: OffsetPoint = { offset, elevation: slopeElevation };
length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation);
points.push(point);
previous = point;
if (slopeElevation <= groundElevation) break;
previousGap = gap;
}
return {
points,
@@ -337,11 +386,12 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const topFrontX = jointX + outward * REVET_THICKNESS_M;
const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE);
if (groundAt(startX) < startE + 0.05) return true;
// 매몰 — 0도 절토선이 원지반과 다시 만나면 허용(2026-08-22 ③), 아니면 이동 금지.
// 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지.
return (
horizontalCrossing(
slopedCrossing(
{ offset: startX, elevation: startE },
outward,
-PIPE_CONNECT_GRADE,
groundAt,
limitOffset,
) !== null
@@ -413,11 +463,15 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
segments.push(tail);
addable = tail.overLimit;
} else {
const cross = horizontalCrossing(src, outward, groundAt, limitOffset);
const cross = slopedCrossing(src, outward, -PIPE_CONNECT_GRADE, groundAt, limitOffset);
if (cross !== null) {
const end: OffsetPoint = {
offset: cross,
elevation: src.elevation - PIPE_CONNECT_GRADE * Math.abs(cross - src.offset),
};
segments.push({
points: [src, { offset: cross, elevation: src.elevation }],
lengthM: Math.abs(cross - src.offset),
points: [src, end],
lengthM: Math.hypot(end.offset - src.offset, end.elevation - src.elevation),
overLimit: false,
kind: "cut",
});
@@ -8,6 +8,7 @@ import type { SectionSample } from "./B06_Section_Api_Fetch";
import {
FILL_MIN_RISE_M,
FILL_SLOPE_MAX_LENGTH_M,
PIPE_CONNECT_GRADE,
FILL_SLOPE_RATIO_MIN,
REVET_HEIGHT_STEP_M,
REVET_LEAN_RATIO,
@@ -15,7 +16,7 @@ import {
REVET_TRAP_TOP_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { OffsetPoint, PipeEnd, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { horizontalCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */
export function fillWallBaseWidth(height: number): number {
@@ -386,9 +387,10 @@ export function placePipeWall(input: {
if (input.requireCrossing) {
const placeable = (anchorOffset: number, invertE: number): boolean =>
invertE >= input.groundAt(anchorOffset) - 0.01 ||
horizontalCrossing(
slopedCrossing(
{ offset: anchorOffset, elevation: invertE },
input.outward,
-PIPE_CONNECT_GRADE,
input.groundAt,
input.limitOffset,
) !== null;
@@ -9,6 +9,11 @@ import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert";
import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import { materialLabel, materialLimit } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import {
designInterpolator,
groundInterpolator,
slopeToeOffset,
} from "./B06_Section_UI_Cross_Culvert_Solve";
/**
* 기슭막이 4축 조작 제어(2026-08-22 확정) — 벽을 눌러 고르고 조정창에서
* 좌우(x)·상하(d, 성토선 대각)·높이(h)·재질(m)을 만진다. 고른 벽은 여기 담아 두어
@@ -70,15 +75,7 @@ export function computeCardCulvert(
const layout = computeCulvertLayout(
section,
sourceSamples,
revetOffset
? {
inlet: revetOffset.adjustFor(section, "inlet"),
outlet: revetOffset.adjustFor(section, "outlet"),
extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) =>
revetOffset.adjustFor(section, `extra${i}`),
),
}
: undefined,
adjustsInput(section, revetOffset, extraWalls),
inletStructure?.valueFor(section),
equalizeExtras,
);
@@ -147,3 +144,41 @@ export function computeCardCulvert(
}
return layout;
}
function adjustsInput(
section: CrossSection,
revetOffset?: RevetOffsetControl,
extraWalls?: ExtraWallControl,
): { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[] } | undefined {
if (!revetOffset) return undefined;
return {
inlet: revetOffset.adjustFor(section, "inlet"),
outlet: revetOffset.adjustFor(section, "outlet"),
extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) =>
revetOffset.adjustFor(section, `extra${i}`),
),
};
}
/**
* **구조물 없는 상태**의 설계 절·성토선이 원지반과 만나는 지점이 들어오는 데
* 필요한 표시 반폭(m, 여유 0.5 포함) — 2026-08-22 사용자 확정 줌 기준.
* 구조물(기슭막이·집수정)이 어디로 가든 화면 기준은 지형·설계선이 정한다.
*/
export function culvertRequiredHalfWidth(section: CrossSection): number | null {
const design = section.design;
if (!design) return null;
const groundAt = groundInterpolator(section.samples);
const designAt = designInterpolator(design.design_line);
const edges = design.road_edges;
if (!groundAt || !designAt || !edges) return null;
const offsets = section.samples.map((sample) => sample.offset_m ?? 0);
const limits = { left: Math.max(...offsets), right: Math.min(...offsets) };
let extent = 0;
for (const side of ["left", "right"] as const) {
const edge = side === "left" ? edges.left : edges.right;
const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limits[side]);
extent = Math.max(extent, Math.abs(toe));
}
return extent > 0 ? extent + 0.5 : null;
}
+9 -3
View File
@@ -24,7 +24,7 @@ import {
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire";
import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
InletStructureControl,
@@ -202,8 +202,14 @@ export function createCrossSectionCard(
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
extraWalls?: ExtraWallControl,
): CrossCardElement {
// 이 카드의 실효 표시 반폭 — 개별값 전역 반폭보다 우선한다(2026-08-06).
const effectiveHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth;
// 실효 표시 반폭 — 개별값 > 전역(2026-08-06). **구조물 없는 상태**의 절·성토선이
// 원지반과 만나는 지점이 반폭 밖이면 이 카드만 자동 줌아웃(2026-08-22 사용자).
const baseHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth;
const requiredHalfWidth = culvertRequiredHalfWidth(section);
const effectiveHalfWidth =
baseHalfWidth !== undefined && requiredHalfWidth !== null
? Math.max(baseHalfWidth, requiredHalfWidth)
: baseHalfWidth;
const card: CrossCardElement = document.createElement("article");
card.id = `cross-${section.station_id}`;
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;