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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" : ""}`;
|
||||
|
||||
Reference in New Issue
Block a user