feat(B06): 다단 등간격 배치 버튼 + 유입 0도 접속선 + 유출 매몰 절토선·이동 금지
① 십자 우측 하단 ≡ 버튼 — 벽 사이 사면 구간을 같게 재배치(끝 성토부는 지형 결정값이라 제외). 다단 연쇄(아랫단 바닥 하강) 때문에 1회 분할이 아니라 구간 오차를 d로 되먹임하는 수렴 반복(≤8회, 허용 0.05m)으로 푼다. ② 유입 기슭막이 관 시작 접속선: 관 하단점이 원지반보다 위면 0도 성토선 (지반 교차에서 정지), 아래면 0도 1m 후 표준 절토 경사(1:n)로 지반까지. ③ 유출 마지막 구조물 시작점이 원지반에 묻히면 0도 절토선을 지반 교차까지 연장, 교차가 없는 자리는 이동 금지(배관 벽·추가 벽 공통 클램프). - 배관 벽 4축 배치를 placePipeWall(Solve)로 추출 — Geom 700줄 유지 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,31 +74,53 @@ export function appendCulvertOverlay(
|
||||
|
||||
// ④ 유출구측 성토부선(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로).
|
||||
// 구간별 폴리라인: 관 하단 꼭짓점(또는 앞 추가 벽 전면 상단) → 다음 벽/원지반.
|
||||
const planLine = (points: (typeof pipe.inlet)[], tooltip: string): void => {
|
||||
if (points.length < 2) return;
|
||||
const line = document.createElementNS(SVG_NS, "polyline");
|
||||
line.setAttribute(
|
||||
"points",
|
||||
points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "),
|
||||
);
|
||||
// 성토·절토 접속선은 공사 계획선 — 설계선과 같은 보라 실선.
|
||||
line.setAttribute("class", "b06-chart__design-cross");
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = tooltip;
|
||||
line.append(title);
|
||||
layer.append(line);
|
||||
};
|
||||
const drawOutletFill = (): void => {
|
||||
for (const segment of layout.outletFill.segments) {
|
||||
if (segment.points.length < 2) continue;
|
||||
const line = document.createElementNS(SVG_NS, "polyline");
|
||||
line.setAttribute(
|
||||
"points",
|
||||
segment.points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "),
|
||||
);
|
||||
// 성토부선은 공사 계획선 — 설계선과 같은 보라 실선(집수정 절토·성토선과 동일).
|
||||
line.setAttribute("class", "b06-chart__design-cross");
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent =
|
||||
if (segment.kind === "cut") {
|
||||
// 매몰 구간 — 0도 절토선을 원지반 교차까지(2026-08-22 사용자 ③).
|
||||
planLine(
|
||||
segment.points,
|
||||
`유출부 0도 절토선 — 벽이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
planLine(
|
||||
segment.points,
|
||||
`유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
(segment.overLimit
|
||||
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
|
||||
: " (법정 5m 이내)") +
|
||||
` · 성토 물매 1:${layout.fillSlope.ratio.toFixed(2)}` +
|
||||
(layout.fillSlope.roadWideningM > 0.01
|
||||
? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m`
|
||||
: "") +
|
||||
(layout.fillSlope.structureRequired
|
||||
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
|
||||
: "");
|
||||
line.append(title);
|
||||
layer.append(line);
|
||||
(segment.overLimit
|
||||
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
|
||||
: " (법정 5m 이내)") +
|
||||
` · 성토 물매 1:${(segment.ratio ?? layout.fillSlope.ratio).toFixed(2)}` +
|
||||
(layout.fillSlope.roadWideningM > 0.01
|
||||
? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m`
|
||||
: "") +
|
||||
(layout.fillSlope.structureRequired
|
||||
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
// 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②).
|
||||
if (layout.inletFill) {
|
||||
planLine(
|
||||
layout.inletFill.points,
|
||||
layout.inletFill.kind === "cut"
|
||||
? `유입부 접속 절토선 — 관 하단에서 0도 1m 후 표준 절토 경사로 원지반까지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`
|
||||
: `유입부 0도 성토선 — 관 하단에서 수평, 원지반 교차에서 정지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface OutletExtrasInput {
|
||||
limitOffset: number;
|
||||
/** 단별 사용자 조작값(좌우 x·상하 d·높이 h·재질 m) — 요청한 단 수만큼. */
|
||||
adjusts: WallAdjust[];
|
||||
/** 1회성 등간격 배치(2026-08-22 사용자 ①) — 단별 d를 사면 구간이 같아지게 재계산. */
|
||||
equalize?: boolean;
|
||||
}
|
||||
|
||||
export interface OutletExtrasResult {
|
||||
@@ -52,6 +54,75 @@ export interface OutletExtrasResult {
|
||||
addable: boolean;
|
||||
}
|
||||
|
||||
/** 수평선(0도)이 계류측으로 나가며 원지반 아래로 다시 떨어지는 첫 지점(절토 교차).
|
||||
* 없으면 null — 그 자리는 매몰 상태로 둘 수 없다(2026-08-22 사용자 ③ 이동 금지). */
|
||||
export function horizontalCrossing(
|
||||
from: OffsetPoint,
|
||||
outward: 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 x = from.offset + outward * t;
|
||||
if (groundAt(x) <= from.elevation) return x;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②).
|
||||
* · 관 하단점이 원지반보다 **위**: 0도 성토선 — 지반과 만나는 곳에서 정지.
|
||||
* · **아래**: 0도 직선 1m 후 표준 절토 경사(1:n)로 지반까지 올라가는 절토선.
|
||||
*/
|
||||
export function inletGroundConnector(
|
||||
corner: OffsetPoint,
|
||||
outward: number,
|
||||
groundAt: (offset: number) => number,
|
||||
cutSlopeRatio: number,
|
||||
limitOffset: number,
|
||||
): OutletFillSegment | null {
|
||||
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);
|
||||
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;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
points: [corner, { offset: cross, elevation: corner.elevation }],
|
||||
lengthM: Math.abs(cross - corner.offset),
|
||||
overLimit: false,
|
||||
kind: "fill",
|
||||
};
|
||||
}
|
||||
if (corner.elevation < ground - 0.02) {
|
||||
const bend: OffsetPoint = {
|
||||
offset: corner.offset + outward * 1.0,
|
||||
elevation: corner.elevation,
|
||||
};
|
||||
let last = bend;
|
||||
const cutSpan = (limitOffset - bend.offset) * outward;
|
||||
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);
|
||||
last = { offset: x, elevation };
|
||||
if (elevation >= groundAt(x)) break;
|
||||
}
|
||||
return {
|
||||
points: [corner, bend, last],
|
||||
lengthM: 1 + Math.hypot(last.offset - bend.offset, last.elevation - bend.elevation),
|
||||
overLimit: false,
|
||||
kind: "cut",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */
|
||||
function jointRunOf(height: number): number {
|
||||
return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height;
|
||||
@@ -160,162 +231,217 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
const { outward, groundAt, limitOffset } = input;
|
||||
const thickness = REVET_THICKNESS_M;
|
||||
const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN;
|
||||
const walls: WallLayout[] = [];
|
||||
const segments: OutletFillSegment[] = [];
|
||||
const appliedAdjusts: WallAdjust[] = [];
|
||||
let src = input.start;
|
||||
let prevBottom = input.startBottomElevation;
|
||||
/** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */
|
||||
const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => {
|
||||
const walls: WallLayout[] = [];
|
||||
const segments: OutletFillSegment[] = [];
|
||||
const appliedAdjusts: WallAdjust[] = [];
|
||||
let src = input.start;
|
||||
let prevBottom = input.startBottomElevation;
|
||||
|
||||
for (let i = 0; i < input.adjusts.length; i += 1) {
|
||||
// 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료.
|
||||
if (groundAt(src.offset) >= src.elevation - 0.01) break;
|
||||
const trailing = trailingFill(src, outward, groundAt);
|
||||
if (trailing.lengthM < 0.7) break;
|
||||
for (let i = 0; i < input.adjusts.length; i += 1) {
|
||||
// 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료.
|
||||
if (groundAt(src.offset) >= src.elevation - 0.01) break;
|
||||
const trailing = trailingFill(src, outward, groundAt);
|
||||
if (trailing.lengthM < 0.7) break;
|
||||
|
||||
const adjust = input.adjusts[i];
|
||||
const material = adjust.m ?? "dry";
|
||||
const limit = materialLimit(material);
|
||||
const height = Math.min(
|
||||
Math.max(adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, EXTRA_WALL_MIN_HEIGHT_M),
|
||||
limit,
|
||||
);
|
||||
|
||||
/** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */
|
||||
const heightAt = (x: number): number => {
|
||||
const run = (x - src.offset) * outward;
|
||||
return (
|
||||
(src.elevation - groundAt(x) - (run - 0.25 * thickness) / FILL_SLOPE_RATIO_MIN) /
|
||||
heightDenominator
|
||||
const adjust = input.adjusts[i];
|
||||
const material = adjust.m ?? "dry";
|
||||
const limit = materialLimit(material);
|
||||
const height = Math.min(
|
||||
Math.max(adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, EXTRA_WALL_MIN_HEIGHT_M),
|
||||
limit,
|
||||
);
|
||||
};
|
||||
// 자동 자리 = heightAt이 사용자 높이 h에 처음 닿는 지점(0.05m 스캔 후 선형 보간).
|
||||
// 지형이 완만해 h까지 못 크면 **가장 깊이 앉는 지점**(heightAt 최대)으로 폴백 —
|
||||
// 바닥은 상단−h−0.5라 지반과 어긋나도 된다(뜨면 경고, 묻히면 그대로).
|
||||
const step = 0.05;
|
||||
const span = (limitOffset - src.offset) * outward;
|
||||
let autoOffset: number | null = null;
|
||||
let bestOffset: number | null = null;
|
||||
let bestHeight = 0.05; // 이 이하로만 앉는 지형이면 단을 세우지 않는다
|
||||
let previousShort = 0; // 직전 스캔점의 h 부족량(heightAt < h) — 보간용
|
||||
for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) {
|
||||
const x = src.offset + outward * t;
|
||||
const gap = heightAt(x) - height;
|
||||
if (gap >= 0) {
|
||||
// 교차점 선형 보간 — 격자 대신 정확 자리(사면선·지반 동시 접점).
|
||||
const back = previousShort + gap > 1e-9 ? (gap / (previousShort + gap)) * step : 0;
|
||||
autoOffset = x - outward * back;
|
||||
break;
|
||||
}
|
||||
previousShort = -gap;
|
||||
if (heightAt(x) > bestHeight) {
|
||||
bestHeight = heightAt(x);
|
||||
bestOffset = x;
|
||||
}
|
||||
}
|
||||
if (autoOffset === null) autoOffset = bestOffset;
|
||||
if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만.
|
||||
// 관통 금지: 상단 ≤ 윗단 하단 → 성토선 수평거리(경사부) ≥ 1.2×(시작−윗단하단).
|
||||
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(height);
|
||||
const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0);
|
||||
const minD = Math.ceil((minJointRun - autoJointRun) * 10) / 10;
|
||||
/** 후보 자리의 바닥(전면 근입 심화 반영) — buildExtraWall과 같은 계산. */
|
||||
const bottomAt = (anchorX: number, baseElevation: number): number => {
|
||||
const baseWidth = REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
|
||||
const jointX = anchorX - outward * (baseWidth / 2 - REVET_THICKNESS_M / 2);
|
||||
const topFrontX = jointX + outward * REVET_THICKNESS_M;
|
||||
const topElevation2 = baseElevation + height;
|
||||
const frontXAt = (e: number): number =>
|
||||
topFrontX + outward * REVET_LEAN_RATIO * (topElevation2 - e);
|
||||
let bottom = baseElevation - REVET_EMBED_DEPTH_M;
|
||||
if (baseElevation - groundAt(anchorX) <= 1e-6) {
|
||||
for (let pass = 0; pass < 6; pass += 1) {
|
||||
const toe = Math.min(groundAt(frontXAt(bottom)), baseElevation);
|
||||
if (toe - REVET_EMBED_DEPTH_M >= bottom - 1e-6) break;
|
||||
bottom = toe - REVET_EMBED_DEPTH_M;
|
||||
|
||||
/** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */
|
||||
const heightAt = (x: number): number => {
|
||||
const run = (x - src.offset) * outward;
|
||||
return (
|
||||
(src.elevation - groundAt(x) - (run - 0.25 * thickness) / FILL_SLOPE_RATIO_MIN) /
|
||||
heightDenominator
|
||||
);
|
||||
};
|
||||
// 자동 자리 = heightAt이 사용자 높이 h에 처음 닿는 지점(0.05m 스캔 후 선형 보간).
|
||||
// 지형이 완만해 h까지 못 크면 **가장 깊이 앉는 지점**(heightAt 최대)으로 폴백 —
|
||||
// 바닥은 상단−h−0.5라 지반과 어긋나도 된다(뜨면 경고, 묻히면 그대로).
|
||||
const step = 0.05;
|
||||
const span = (limitOffset - src.offset) * outward;
|
||||
let autoOffset: number | null = null;
|
||||
let bestOffset: number | null = null;
|
||||
let bestHeight = 0.05; // 이 이하로만 앉는 지형이면 단을 세우지 않는다
|
||||
let previousShort = 0; // 직전 스캔점의 h 부족량(heightAt < h) — 보간용
|
||||
for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) {
|
||||
const x = src.offset + outward * t;
|
||||
const gap = heightAt(x) - height;
|
||||
if (gap >= 0) {
|
||||
// 교차점 선형 보간 — 격자 대신 정확 자리(사면선·지반 동시 접점).
|
||||
const back = previousShort + gap > 1e-9 ? (gap / (previousShort + gap)) * step : 0;
|
||||
autoOffset = x - outward * back;
|
||||
break;
|
||||
}
|
||||
previousShort = -gap;
|
||||
if (heightAt(x) > bestHeight) {
|
||||
bestHeight = heightAt(x);
|
||||
bestOffset = x;
|
||||
}
|
||||
}
|
||||
return bottom;
|
||||
};
|
||||
// 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이
|
||||
// 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이
|
||||
// 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리).
|
||||
const placeable = (xShift: number, dShift: number): boolean => {
|
||||
const topE = src.elevation - (autoJointRun + dShift) / FILL_SLOPE_RATIO_MIN;
|
||||
const aX = autoOffset + outward * (xShift + dShift);
|
||||
const jointX = aX - outward * jointRunOf(height);
|
||||
if (Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >= topE - 0.01)
|
||||
return false;
|
||||
const baseE = topE - height;
|
||||
const bottom = bottomAt(aX, baseE);
|
||||
const startE = bottom + REVET_EMBED_DEPTH_M;
|
||||
const topFrontX = jointX + outward * REVET_THICKNESS_M;
|
||||
const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE);
|
||||
return groundAt(startX) < startE + 0.05;
|
||||
};
|
||||
let appliedD = Math.max(adjust.d, minD);
|
||||
let appliedX = Math.max(adjust.x, 0);
|
||||
for (let pass = 0; pass < 400 && !placeable(appliedX, appliedD); pass += 1) {
|
||||
// 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
|
||||
if (appliedD > minD + 1e-9) appliedD = Math.max(minD, appliedD - 0.05);
|
||||
else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05);
|
||||
else break;
|
||||
if (autoOffset === null) autoOffset = bestOffset;
|
||||
if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만.
|
||||
// 관통 금지: 상단 ≤ 윗단 하단 → 성토선 수평거리(경사부) ≥ 1.2×(시작−윗단하단).
|
||||
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(height);
|
||||
const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0);
|
||||
// 등간격 배치: 1차는 남은 구간(끝 포함) 균등 분할 근사, 이후 라운드는 보정 d.
|
||||
const requestedD =
|
||||
plan === "user"
|
||||
? adjust.d
|
||||
: plan === "greedy"
|
||||
? (trailing.lengthM / (input.adjusts.length - i + 1)) *
|
||||
(FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN)) -
|
||||
autoJointRun
|
||||
: (plan[i] ?? 0);
|
||||
const requestedX = plan === "user" ? adjust.x : 0;
|
||||
const minD = Math.ceil((minJointRun - autoJointRun) * 10) / 10;
|
||||
/** 후보 자리의 바닥(전면 근입 심화 반영) — buildExtraWall과 같은 계산. */
|
||||
const bottomAt = (anchorX: number, baseElevation: number): number => {
|
||||
const baseWidth = REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
|
||||
const jointX = anchorX - outward * (baseWidth / 2 - REVET_THICKNESS_M / 2);
|
||||
const topFrontX = jointX + outward * REVET_THICKNESS_M;
|
||||
const topElevation2 = baseElevation + height;
|
||||
const frontXAt = (e: number): number =>
|
||||
topFrontX + outward * REVET_LEAN_RATIO * (topElevation2 - e);
|
||||
let bottom = baseElevation - REVET_EMBED_DEPTH_M;
|
||||
if (baseElevation - groundAt(anchorX) <= 1e-6) {
|
||||
for (let pass = 0; pass < 6; pass += 1) {
|
||||
const toe = Math.min(groundAt(frontXAt(bottom)), baseElevation);
|
||||
if (toe - REVET_EMBED_DEPTH_M >= bottom - 1e-6) break;
|
||||
bottom = toe - REVET_EMBED_DEPTH_M;
|
||||
}
|
||||
}
|
||||
return bottom;
|
||||
};
|
||||
// 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이
|
||||
// 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이
|
||||
// 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리).
|
||||
const placeable = (xShift: number, dShift: number): boolean => {
|
||||
const topE = src.elevation - (autoJointRun + dShift) / FILL_SLOPE_RATIO_MIN;
|
||||
const aX = autoOffset + outward * (xShift + dShift);
|
||||
const jointX = aX - outward * jointRunOf(height);
|
||||
if (
|
||||
Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >=
|
||||
topE - 0.01
|
||||
)
|
||||
return false;
|
||||
const baseE = topE - height;
|
||||
const bottom = bottomAt(aX, baseE);
|
||||
const startE = bottom + REVET_EMBED_DEPTH_M;
|
||||
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 ③), 아니면 이동 금지.
|
||||
return (
|
||||
horizontalCrossing(
|
||||
{ offset: startX, elevation: startE },
|
||||
outward,
|
||||
groundAt,
|
||||
limitOffset,
|
||||
) !== null
|
||||
);
|
||||
};
|
||||
let appliedD = Math.max(requestedD, minD);
|
||||
let appliedX = Math.max(requestedX, 0);
|
||||
for (let pass = 0; pass < 400 && !placeable(appliedX, appliedD); pass += 1) {
|
||||
// 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
|
||||
if (appliedD > minD + 1e-9) appliedD = Math.max(minD, appliedD - 0.05);
|
||||
else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05);
|
||||
else break;
|
||||
}
|
||||
if (!placeable(appliedX, appliedD)) break; // 자동 자리조차 매몰 — 이 단은 불가.
|
||||
appliedD = Math.round(appliedD * 10) / 10;
|
||||
appliedX = Math.round(appliedX * 10) / 10;
|
||||
appliedAdjusts.push({
|
||||
x: appliedX,
|
||||
d: appliedD,
|
||||
h: adjust.h != null ? height : null,
|
||||
m: adjust.m,
|
||||
});
|
||||
|
||||
const slopeRun = autoJointRun + appliedD;
|
||||
const topElevation = src.elevation - slopeRun / FILL_SLOPE_RATIO_MIN;
|
||||
const anchorX = autoOffset + outward * (appliedX + appliedD);
|
||||
const base = topElevation - height; // 근입 0.5 위 기준선
|
||||
const wall = buildExtraWall(
|
||||
i,
|
||||
{ offset: anchorX, elevation: base },
|
||||
outward,
|
||||
height,
|
||||
material,
|
||||
Math.max(0, base - groundAt(anchorX)),
|
||||
groundAt,
|
||||
);
|
||||
walls.push(wall);
|
||||
// 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부.
|
||||
const shelf: OffsetPoint | null =
|
||||
appliedX > 1e-9
|
||||
? { offset: src.offset + outward * appliedX, elevation: src.elevation }
|
||||
: null;
|
||||
const slopeFrom = shelf ?? src;
|
||||
const run = Math.abs(wall.topJoint.offset - slopeFrom.offset);
|
||||
const rise = slopeFrom.elevation - wall.topJoint.elevation;
|
||||
segments.push({
|
||||
points: shelf ? [src, shelf, wall.topJoint] : [src, wall.topJoint],
|
||||
lengthM: Math.hypot(run, rise),
|
||||
ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN,
|
||||
overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
|
||||
});
|
||||
// 다음 단 시작점 = 이 벽 **실제 바닥**(근입 심화 반영) +0.5m 수평선과 전면
|
||||
// 경사선(1:0.3)의 교차점(2026-08-22 사용자 — 관 끝점 규칙과 동일 기준).
|
||||
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
|
||||
src = {
|
||||
offset:
|
||||
wall.points[2].offset +
|
||||
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
|
||||
elevation: startElevation,
|
||||
};
|
||||
prevBottom = wall.bottomBack.elevation;
|
||||
}
|
||||
if (!placeable(appliedX, appliedD)) break; // 자동 자리조차 매몰 — 이 단은 불가.
|
||||
appliedD = Math.round(appliedD * 10) / 10;
|
||||
appliedX = Math.round(appliedX * 10) / 10;
|
||||
appliedAdjusts.push({
|
||||
x: appliedX,
|
||||
d: appliedD,
|
||||
h: adjust.h != null ? height : null,
|
||||
m: adjust.m,
|
||||
});
|
||||
|
||||
const slopeRun = autoJointRun + appliedD;
|
||||
const topElevation = src.elevation - slopeRun / FILL_SLOPE_RATIO_MIN;
|
||||
const anchorX = autoOffset + outward * (appliedX + appliedD);
|
||||
const base = topElevation - height; // 근입 0.5 위 기준선
|
||||
const wall = buildExtraWall(
|
||||
i,
|
||||
{ offset: anchorX, elevation: base },
|
||||
outward,
|
||||
height,
|
||||
material,
|
||||
Math.max(0, base - groundAt(anchorX)),
|
||||
groundAt,
|
||||
// 끝 구간(2026-08-22 사용자 ③): 시작점이 지반 위면 성토부선, 지반 아래(매몰)면
|
||||
// **0도 절토선**을 원지반 교차까지 연장한다(교차가 없는 자리는 placeable이 막는다).
|
||||
let addable = false;
|
||||
if (groundAt(src.offset) < src.elevation - 0.01) {
|
||||
const tail = trailingFill(src, outward, groundAt);
|
||||
segments.push(tail);
|
||||
addable = tail.overLimit;
|
||||
} else {
|
||||
const cross = horizontalCrossing(src, outward, groundAt, limitOffset);
|
||||
if (cross !== null) {
|
||||
segments.push({
|
||||
points: [src, { offset: cross, elevation: src.elevation }],
|
||||
lengthM: Math.abs(cross - src.offset),
|
||||
overLimit: false,
|
||||
kind: "cut",
|
||||
});
|
||||
}
|
||||
}
|
||||
return { walls, segments, appliedAdjusts, addable };
|
||||
};
|
||||
|
||||
if (!input.equalize) return cascadeOnce("user");
|
||||
// 등간격(2026-08-22 ①) = **벽 사이 사면 구간**을 같게(끝 성토부는 지형이 정하는
|
||||
// 값이라 제외). 다단은 아래 단 바닥이 깊어질수록 뒷구간이 줄어드는 연쇄라 1회
|
||||
// 분할로는 안 맞는다 — 현 자리에서 구간 오차를 d로 되먹임하며 수렴시킨다.
|
||||
let result = cascadeOnce("user");
|
||||
const horizontalPerSlope = FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN);
|
||||
for (let round = 0; round < 8; round += 1) {
|
||||
const count = result.walls.length;
|
||||
if (count < 2) break; // 한 단이면 등간격 개념이 없다
|
||||
const lengths = result.segments.slice(0, count).map((segment) => segment.lengthM);
|
||||
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
||||
if (Math.max(...lengths.map((l) => Math.abs(l - mean))) < 0.05) break;
|
||||
const dNext = result.appliedAdjusts.map(
|
||||
(applied, i) => applied.d + (mean - (lengths[i] ?? mean)) * horizontalPerSlope,
|
||||
);
|
||||
walls.push(wall);
|
||||
// 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부.
|
||||
const shelf: OffsetPoint | null =
|
||||
appliedX > 1e-9
|
||||
? { offset: src.offset + outward * appliedX, elevation: src.elevation }
|
||||
: null;
|
||||
const slopeFrom = shelf ?? src;
|
||||
const run = Math.abs(wall.topJoint.offset - slopeFrom.offset);
|
||||
const rise = slopeFrom.elevation - wall.topJoint.elevation;
|
||||
segments.push({
|
||||
points: shelf ? [src, shelf, wall.topJoint] : [src, wall.topJoint],
|
||||
lengthM: Math.hypot(run, rise),
|
||||
ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN,
|
||||
overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
|
||||
});
|
||||
// 다음 단 시작점 = 이 벽 **실제 바닥**(근입 심화 반영) +0.5m 수평선과 전면
|
||||
// 경사선(1:0.3)의 교차점(2026-08-22 사용자 — 관 끝점 규칙과 동일 기준).
|
||||
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
|
||||
src = {
|
||||
offset:
|
||||
wall.points[2].offset +
|
||||
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
|
||||
elevation: startElevation,
|
||||
};
|
||||
prevBottom = wall.bottomBack.elevation;
|
||||
result = cascadeOnce(dNext);
|
||||
}
|
||||
|
||||
// 끝 성토부 — 마지막 구조물의 시작점이 지반 위일 때만 그린다(묻히면 성토 불필요).
|
||||
let addable = false;
|
||||
if (groundAt(src.offset) < src.elevation - 0.01) {
|
||||
const tail = trailingFill(src, outward, groundAt);
|
||||
segments.push(tail);
|
||||
addable = tail.overLimit;
|
||||
}
|
||||
return { walls, segments, appliedAdjusts, addable };
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
buildBasin,
|
||||
inletChoiceAvailability,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Basin";
|
||||
import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { buildOutletExtras, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import {
|
||||
clampToFace,
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
designInterpolator,
|
||||
intersect,
|
||||
slopeToeOffset,
|
||||
placePipeWall,
|
||||
slopeLengthAlong,
|
||||
STRAY_LIMIT_M,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
@@ -78,6 +79,8 @@ export function computeCulvertLayout(
|
||||
revetShift?: { inlet?: WallAdjust; outlet?: WallAdjust; extras?: WallAdjust[] },
|
||||
/** 유입측 구조물 형식 선택(드롭다운) — 없으면 auto(규칙). */
|
||||
inletStructure?: InletStructureChoice,
|
||||
/** 다단 등간격 배치 1회성 트리거(2026-08-22 사용자 ①). */
|
||||
equalizeExtras?: boolean,
|
||||
): CulvertLayout | null {
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return null;
|
||||
@@ -91,7 +94,7 @@ export function computeCulvertLayout(
|
||||
const maxSample = Math.max(...sampleOffsets);
|
||||
if (!(maxSample > minSample)) return null;
|
||||
|
||||
// 좌표 규약: +offset = 좌측(화면 왼쪽). 상단측이 유입이다(미상이면 좌측 폴백).
|
||||
// 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백).
|
||||
const uphill = section.uphill_side ?? "left";
|
||||
const sideInfo = (side: "left" | "right") => ({
|
||||
edge: side === "left" ? edges.left : edges.right,
|
||||
@@ -101,17 +104,14 @@ export function computeCulvertLayout(
|
||||
const inletInfo = sideInfo(uphill === "left" ? "left" : "right");
|
||||
const outletInfo = sideInfo(uphill === "left" ? "right" : "left");
|
||||
|
||||
// 유입 invert: 유입 노견 아래 원지반. 절토측이면 "노면 − 관경 − 토피"가 상한
|
||||
// (관 상단 + 토피가 노면 안에 들어가는 최고 자리 — B05 하향 차단식과 동일).
|
||||
// 유입 invert: 노견 아래 원지반. 상한 = 노면 − 관경 − 토피(B05 하향 차단식과 동일).
|
||||
const diameter = culvert.diameter_m;
|
||||
const invertCap = (edge: { offset_m: number; elevation_m: number }): number =>
|
||||
(designAt ? designAt(edge.offset_m) : edge.elevation_m) - diameter - MIN_PIPE_COVER_M;
|
||||
const inletInvert = Math.min(groundAt(inletInfo.edge.offset_m), invertCap(inletInfo.edge));
|
||||
const inlet: OffsetPoint = { offset: inletInfo.edge.offset_m, elevation: inletInvert };
|
||||
|
||||
// 유입측 집수정 판정(2026-08-22 사용자 재정의): **성토사면 길이 ≤ 3m**면 기슭막이
|
||||
// +배관을 두기 어려워 집수정이 기본이 된다(종전 절토/30% 막힘 판정 대체 — 절토측
|
||||
// 유입은 성토사면이 없으므로 자동 포함). 사용자 선택(기슭막이 유지)은 향후 추가.
|
||||
// 유입측 집수정 판정(2026-08-22 재정의): 성토사면 ≤3m면 집수정 기본(절토 포함).
|
||||
const mode = section.design?.section_mode;
|
||||
const inletSideName: "left" | "right" = uphill === "left" ? "left" : "right";
|
||||
const inletIsCut =
|
||||
@@ -203,30 +203,29 @@ export function computeCulvertLayout(
|
||||
Math.min(groundAt(offset), invertCap(inletInfo.edge));
|
||||
// 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용.
|
||||
inletAutoOffset = inletOptions.revetAutoOffset;
|
||||
// 4축 조작(2026-08-22 확정): 좌우 x = 노견 연장 **평행이동**(상단 표고·물매
|
||||
// 1:1.2 불변, 안쪽 x<0 금지 — 자동 자리가 이미 노견 최소), 상하 d = 성토선을
|
||||
// 타는 대각 이동(수평 성분, +아래). 위(−d) 한계 = 최소 성토고·토피 상한.
|
||||
// 바닥이 지반에 못 닿으면 떠도 무관(별도 지지 구조물 예정).
|
||||
const h = inletWallSpec.height;
|
||||
const top0 = inletBaseAt(inletAutoOffset) + h;
|
||||
const dLow = Math.max(
|
||||
-FILL_SLOPE_RATIO_MIN * (inletInfo.edge.elevation_m - FILL_MIN_RISE_M - top0),
|
||||
-FILL_SLOPE_RATIO_MIN * (invertCap(inletInfo.edge) - (top0 - h)),
|
||||
);
|
||||
const appliedD = Math.max(adjInlet.d, Math.ceil(dLow * 10) / 10);
|
||||
const appliedX = Math.max(adjInlet.x, 0);
|
||||
appliedAdjust.inlet.x = appliedX;
|
||||
appliedAdjust.inlet.d = appliedD;
|
||||
const topElev = top0 - appliedD / FILL_SLOPE_RATIO_MIN;
|
||||
const anchorX = inletAutoOffset + inletInfo.outward * (appliedX + appliedD);
|
||||
const invert = topElev - h;
|
||||
// 4축 배치는 공용 풀이(placePipeWall — Solve, 700줄 제한): 좌우 = 노견 연장
|
||||
// 평행이동, 상하 = 성토선 대각, 위 한계 = 최소 성토고·토피 상한.
|
||||
const placed = placePipeWall({
|
||||
autoOffset: inletAutoOffset,
|
||||
outward: inletInfo.outward,
|
||||
height: inletWallSpec.height,
|
||||
baseElevation0: inletBaseAt(inletAutoOffset),
|
||||
edgeElevation: inletInfo.edge.elevation_m,
|
||||
invertCap: invertCap(inletInfo.edge),
|
||||
adjust: adjInlet,
|
||||
groundAt,
|
||||
limitOffset: inletInfo.limit,
|
||||
requireCrossing: false,
|
||||
});
|
||||
appliedAdjust.inlet.x = placed.x;
|
||||
appliedAdjust.inlet.d = placed.d;
|
||||
wallVertical.inlet = {
|
||||
height: h,
|
||||
baseElevation: invert,
|
||||
floatGapM: Math.max(0, invert - inletBaseAt(anchorX)),
|
||||
height: inletWallSpec.height,
|
||||
baseElevation: placed.invert,
|
||||
floatGapM: Math.max(0, placed.invert - inletBaseAt(placed.anchorOffset)),
|
||||
};
|
||||
inlet.offset = anchorX;
|
||||
inlet.elevation = invert;
|
||||
inlet.offset = placed.anchorOffset;
|
||||
inlet.elevation = placed.invert;
|
||||
}
|
||||
|
||||
// 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2).
|
||||
@@ -256,9 +255,7 @@ export function computeCulvertLayout(
|
||||
const span = Math.abs(outletAnchor.offset - inlet.offset);
|
||||
let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0;
|
||||
|
||||
// 기슭막이 합성 단면(사용자 ①·②): 하부 = 배면 수직·전면 1:0.3 **사다리꼴**(관이
|
||||
// 지나는 관경 높이), 상부 = 전·배면이 나란히 기운 **평행사변형**. 상단 배면 꼭짓점이
|
||||
// 성토 사면선(설계선)과 만나는 접점이 되도록 높이를 정한다 — 사면선은 거기서 끊긴다.
|
||||
// 기슭막이 합성 단면: 하부 사다리꼴(배면 수직·전면 1:0.3) + 상부 평행사변형 띠.
|
||||
const walls: WallLayout[] = [];
|
||||
let basin: BasinLayout | null = null;
|
||||
/** 집수정이 서면 관 유입단을 내공 안으로 옮긴다(접속 표현). */
|
||||
@@ -401,13 +398,7 @@ export function computeCulvertLayout(
|
||||
wallVertical.inlet,
|
||||
inletWallSpec.material,
|
||||
);
|
||||
// 유출 벽 자리 — 유입과 같은 규칙(사면선이 벽 이음선 상단점을 지나는 자리, 물매는
|
||||
// 1:1.2~2.0 범위에서 역산).
|
||||
//
|
||||
// 벽 밑 = 그 자리의 **원지반**. 종전처럼 관 축(inlet → 사면 끝 직선) 위에 얹으면
|
||||
// 성토측 지반이 관 물매보다 가파른 구간에서 축이 지반 위로 떠올라 **벽이 뜬다**
|
||||
// (2026-08-21 사용자 지적). 관 끝은 원지반 위라는 원칙대로 지반을 따르면 벽이
|
||||
// 지반에 앉고 관 물매가 그 자리에 맞춰진다. 역경사는 유입 invert로 클램프.
|
||||
// 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21).
|
||||
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
|
||||
// 유출 벽 자동 자리도 **노견 최소 지점**(2026-08-22 사용자 확정) — 못 찾으면 사면 끝.
|
||||
let outletWallAnchor = outletAnchor;
|
||||
@@ -423,26 +414,27 @@ export function computeCulvertLayout(
|
||||
: null;
|
||||
const outletAutoOffset = outletFeasible ?? outletAnchorOffset;
|
||||
if (designAt) {
|
||||
// 4축 조작(유입과 동일). 위(−d) 한계 = 최소 성토고 + 역경사 방지(invert ≤ 유입).
|
||||
const h = outletWallSpec.height;
|
||||
const top0 = invertAt(outletAutoOffset) + h;
|
||||
const dLow = Math.max(
|
||||
-FILL_SLOPE_RATIO_MIN * (outletInfo.edge.elevation_m - FILL_MIN_RISE_M - top0),
|
||||
-FILL_SLOPE_RATIO_MIN * (inlet.elevation - (top0 - h)),
|
||||
);
|
||||
const appliedD = Math.max(adjOutlet.d, Math.ceil(dLow * 10) / 10);
|
||||
const appliedX = Math.max(adjOutlet.x, 0);
|
||||
appliedAdjust.outlet.x = appliedX;
|
||||
appliedAdjust.outlet.d = appliedD;
|
||||
const topElev = top0 - appliedD / FILL_SLOPE_RATIO_MIN;
|
||||
const anchorX = outletAutoOffset + outletInfo.outward * (appliedX + appliedD);
|
||||
const invert = topElev - h;
|
||||
// 4축 배치(유입과 동일 풀이) + 유출 전용: 매몰-무교차 자리 금지(2026-08-22 ③).
|
||||
const placed = placePipeWall({
|
||||
autoOffset: outletAutoOffset,
|
||||
outward: outletInfo.outward,
|
||||
height: outletWallSpec.height,
|
||||
baseElevation0: invertAt(outletAutoOffset),
|
||||
edgeElevation: outletInfo.edge.elevation_m,
|
||||
invertCap: inlet.elevation,
|
||||
adjust: adjOutlet,
|
||||
groundAt,
|
||||
limitOffset: outletInfo.limit,
|
||||
requireCrossing: true,
|
||||
});
|
||||
appliedAdjust.outlet.x = placed.x;
|
||||
appliedAdjust.outlet.d = placed.d;
|
||||
wallVertical.outlet = {
|
||||
height: h,
|
||||
baseElevation: invert,
|
||||
floatGapM: Math.max(0, invert - invertAt(anchorX)),
|
||||
height: outletWallSpec.height,
|
||||
baseElevation: placed.invert,
|
||||
floatGapM: Math.max(0, placed.invert - invertAt(placed.anchorOffset)),
|
||||
};
|
||||
outletWallAnchor = { offset: anchorX, elevation: invert };
|
||||
outletWallAnchor = { offset: placed.anchorOffset, elevation: placed.invert };
|
||||
}
|
||||
let outletWall = buildWall(
|
||||
culvert.outlet,
|
||||
@@ -453,11 +445,7 @@ export function computeCulvertLayout(
|
||||
wallVertical.outlet,
|
||||
outletWallSpec.material,
|
||||
);
|
||||
// ── 관 축 확정. 관 하단선은 **관 시작점(집수정 내공 벽 또는 유입 벽 자리)과
|
||||
// 유출 벽 하단선 중점(invert)을 잇는 직선**이다 — 벽이 어디로 가든 관은 그 벽
|
||||
// 바닥을 지난다(2026-08-22 사용자: 기슭막이는 관을 감싸는 구조물). 종전에는 정수
|
||||
// 맞춤이 벽을 옮긴 뒤에도 옛 축을 그대로 써서, 가파른 지반에서 관이 벽 바닥
|
||||
// 아래로 삐져나갔다(13+15.7 사용자 지적).
|
||||
// ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다.
|
||||
const pipeStart = basinPipeEnd ?? inlet;
|
||||
/** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의
|
||||
* 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */
|
||||
@@ -620,6 +608,16 @@ export function computeCulvertLayout(
|
||||
if (basin) {
|
||||
applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt);
|
||||
}
|
||||
// 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위 0도 성토선 / 아래 0도 1m+절토선.
|
||||
const inletFill = !basin
|
||||
? inletGroundConnector(
|
||||
pipeCorners.inlet.bottom,
|
||||
inletInfo.outward,
|
||||
groundAt,
|
||||
section.design?.cut_slope_ratio ?? 1.0,
|
||||
inletInfo.limit,
|
||||
)
|
||||
: null;
|
||||
|
||||
// 성토부선 + 추가 기슭막이(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로
|
||||
// 남긴다). 끝 구간이 5m 이상이면 사용자가 추가 기슭막이(벽만)를 계단식으로 더 둔다.
|
||||
@@ -631,6 +629,7 @@ export function computeCulvertLayout(
|
||||
groundAt,
|
||||
limitOffset: outletInfo.limit,
|
||||
adjusts: (revetShift?.extras ?? []).map(adjustOf),
|
||||
equalize: equalizeExtras === true,
|
||||
})
|
||||
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
|
||||
|
||||
@@ -677,6 +676,7 @@ export function computeCulvertLayout(
|
||||
structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M,
|
||||
},
|
||||
outletFill: { segments: extras.segments, addable: extras.addable },
|
||||
inletFill,
|
||||
extraWalls: extras.walls,
|
||||
basin,
|
||||
revetShift: {
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
REVET_THICKNESS_M,
|
||||
REVET_TRAP_TOP_M,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { OffsetPoint, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { OffsetPoint, PipeEnd, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { horizontalCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
|
||||
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */
|
||||
export function fillWallBaseWidth(height: number): number {
|
||||
@@ -344,3 +345,69 @@ export function slopeLengthAlong(
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
/** 배관 기슭막이 4축 배치 결과 — 좌우 x·상하 d(적용값)와 확정 자리. */
|
||||
export interface PipeWallPlacement {
|
||||
x: number;
|
||||
d: number;
|
||||
topElevation: number;
|
||||
anchorOffset: number;
|
||||
invert: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배관 기슭막이 4축 배치(2026-08-22 확정 규칙 묶음) — 유입·유출 공용.
|
||||
* · x = 노견 연장 평행이동(안쪽 금지), d = 성토선 대각(위 한계 = 최소 성토고·
|
||||
* invert 상한(토피/역경사)).
|
||||
* · requireCrossing(유출): 매몰인데 0도 절토선이 원지반과 다시 못 만나는 자리는
|
||||
* 금지 — 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
|
||||
*/
|
||||
export function placePipeWall(input: {
|
||||
autoOffset: number;
|
||||
outward: number;
|
||||
height: number;
|
||||
/** 자동 자리의 invert(= baseAt(autoOffset)). */
|
||||
baseElevation0: number;
|
||||
edgeElevation: number;
|
||||
/** invert 상한 — 유입: 노면−관경−토피, 유출: 유입 invert(역경사 방지). */
|
||||
invertCap: number;
|
||||
adjust: WallAdjust;
|
||||
groundAt: (offset: number) => number;
|
||||
limitOffset: number;
|
||||
requireCrossing: boolean;
|
||||
}): PipeWallPlacement {
|
||||
const top0 = input.baseElevation0 + input.height;
|
||||
const dLow = Math.max(
|
||||
-FILL_SLOPE_RATIO_MIN * (input.edgeElevation - FILL_MIN_RISE_M - top0),
|
||||
-FILL_SLOPE_RATIO_MIN * (input.invertCap - (top0 - input.height)),
|
||||
);
|
||||
let d = Math.max(input.adjust.d, Math.ceil(dLow * 10) / 10);
|
||||
let x = Math.max(input.adjust.x, 0);
|
||||
if (input.requireCrossing) {
|
||||
const placeable = (anchorOffset: number, invertE: number): boolean =>
|
||||
invertE >= input.groundAt(anchorOffset) - 0.01 ||
|
||||
horizontalCrossing(
|
||||
{ offset: anchorOffset, elevation: invertE },
|
||||
input.outward,
|
||||
input.groundAt,
|
||||
input.limitOffset,
|
||||
) !== null;
|
||||
for (let pass = 0; pass < 400; pass += 1) {
|
||||
const anchorOffset = input.autoOffset + input.outward * (x + d);
|
||||
if (placeable(anchorOffset, top0 - d / FILL_SLOPE_RATIO_MIN - input.height)) break;
|
||||
if (Math.abs(d) > 1e-9) d = d > 0 ? Math.max(0, d - 0.05) : Math.min(0, d + 0.05);
|
||||
else if (x > 1e-9) x = Math.max(0, x - 0.05);
|
||||
else break;
|
||||
}
|
||||
d = Math.round(d * 10) / 10;
|
||||
x = Math.round(x * 10) / 10;
|
||||
}
|
||||
const topElevation = top0 - d / FILL_SLOPE_RATIO_MIN;
|
||||
return {
|
||||
x,
|
||||
d,
|
||||
topElevation,
|
||||
anchorOffset: input.autoOffset + input.outward * (x + d),
|
||||
invert: topElevation - input.height,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface OutletFillSegment {
|
||||
lengthM: number;
|
||||
/** 구간 물매(1:n) — 벽 사이 구간은 높이 역산으로 정확히 1.2다. */
|
||||
ratio?: number;
|
||||
/** 선 성격(2026-08-22 사용자 ②③): fill = 성토선(기본), cut = 0도/접속 절토선. */
|
||||
kind?: "fill" | "cut";
|
||||
/** 사면길이 5m 이상 — 기슭막이 의무 구간(별표2·성토_비탈면 §2). */
|
||||
overLimit: boolean;
|
||||
}
|
||||
@@ -157,6 +159,9 @@ export interface CulvertLayout {
|
||||
};
|
||||
/** 유출측 성토부선(보호공 대체 — 2026-08-22). 끝 구간이 5m 이상이면 추가 가능. */
|
||||
outletFill: { segments: OutletFillSegment[]; addable: boolean };
|
||||
/** 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위면 0도 성토선(지반 정지),
|
||||
* 아래면 0도 1m 후 표준 절토 경사선. 집수정·접속 불필요면 null. */
|
||||
inletFill: OutletFillSegment | null;
|
||||
/** 추가 기슭막이(배관 없는 벽 — 계단식). 조정창 키 = `extra{n}`. */
|
||||
extraWalls: WallLayout[];
|
||||
/** 유입 집수정 단면(있을 때만). */
|
||||
|
||||
@@ -36,6 +36,10 @@ export interface ExtraWallControl {
|
||||
countFor: (section: CrossSection) => number;
|
||||
/** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */
|
||||
setCount: (chainageM: number, count: number) => void;
|
||||
/** 다단 등간격 배치 요청(2026-08-22 ①) — 다음 계산 1회에 적용된다. */
|
||||
equalize: (chainageM: number) => void;
|
||||
/** 등간격 요청을 소비(1회성) — 계산 직전에 Wire가 부른다. */
|
||||
consumeEqualize: (section: CrossSection) => boolean;
|
||||
/** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */
|
||||
syncCount: (chainageM: number, built: number) => void;
|
||||
}
|
||||
@@ -62,6 +66,7 @@ export function computeCardCulvert(
|
||||
inletStructure?: InletStructureControl,
|
||||
extraWalls?: ExtraWallControl,
|
||||
): CulvertLayout | null {
|
||||
const equalizeExtras = extraWalls?.consumeEqualize(section) ?? false;
|
||||
const layout = computeCulvertLayout(
|
||||
section,
|
||||
sourceSamples,
|
||||
@@ -75,6 +80,7 @@ export function computeCardCulvert(
|
||||
}
|
||||
: undefined,
|
||||
inletStructure?.valueFor(section),
|
||||
equalizeExtras,
|
||||
);
|
||||
if (!layout) return null;
|
||||
if (extraWalls && extraWalls.countFor(section) > layout.extraWalls.length) {
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface StructurePanelDeps {
|
||||
extraState: () => { canAdd: boolean; count: number };
|
||||
/** 다단 기슭막이 단 수 지정 — 지형 허용보다 크면 기하가 자르고 토스트로 알린다. */
|
||||
setExtraCount: (count: number) => void;
|
||||
/** 다단 등간격 배치(2026-08-22 ①) — 사면 구간이 같아지게 단들을 재배치. */
|
||||
equalizeExtras: () => void;
|
||||
}
|
||||
|
||||
export interface StructurePanelHandle {
|
||||
@@ -121,7 +123,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
const buttons = moveRow.controls;
|
||||
const dpad = (
|
||||
label: string,
|
||||
slot: "up" | "down" | "left" | "right" | "reset",
|
||||
slot: "up" | "down" | "left" | "right" | "reset" | "equal",
|
||||
title: string,
|
||||
onClick: () => void,
|
||||
): HTMLButtonElement => {
|
||||
@@ -160,6 +162,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
L("B06_Cross_Revet_Down"),
|
||||
act((key) => deps.nudgeSlope(key, STEP_M)),
|
||||
),
|
||||
dpad("≡", "equal", L("B06_Cross_Extra_Equalize"), () => deps.equalizeExtras()),
|
||||
);
|
||||
|
||||
// 재질 행(2026-08-22 사용자 ④ — 높이 행 **위**, 폭 축소: 한계는 툴팁으로).
|
||||
|
||||
@@ -646,6 +646,7 @@ export function createCrossSectionCard(
|
||||
}
|
||||
extraWalls?.setCount(section.chainage_m, count);
|
||||
},
|
||||
equalizeExtras: () => extraWalls?.equalize(section.chainage_m),
|
||||
});
|
||||
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
|
||||
showRevetControl(activeRevet !== null);
|
||||
|
||||
@@ -260,8 +260,17 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
}
|
||||
}
|
||||
|
||||
/** 등간격 배치 1회성 요청(2026-08-22 ①) — 다음 카드 계산에서 소비된다. */
|
||||
const pendingEqualize = new Set<string>();
|
||||
|
||||
const extraWallControl: ExtraWallControl = {
|
||||
countFor: (section) => extraCounts.get(section.chainage_m.toFixed(2)) ?? 0,
|
||||
equalize: (chainageM) => {
|
||||
if ((extraCounts.get(chainageM.toFixed(2)) ?? 0) <= 0) return; // 단이 없으면 무의미
|
||||
pendingEqualize.add(chainageM.toFixed(2));
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
consumeEqualize: (section) => pendingEqualize.delete(section.chainage_m.toFixed(2)),
|
||||
setCount: (chainageM, requested) => {
|
||||
const key = chainageM.toFixed(2);
|
||||
const previous = extraCounts.get(key) ?? 0;
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
grid-template-areas:
|
||||
". up ."
|
||||
"left reset right"
|
||||
". down .";
|
||||
". down equal";
|
||||
/* 십자 그룹은 패널 폭 기준 수평 가운데(2026-08-22 사용자) — 버튼 크기는 유지. */
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
@@ -641,6 +641,10 @@
|
||||
grid-area: reset;
|
||||
}
|
||||
|
||||
.b06-structure-panel__btn--equal {
|
||||
grid-area: equal;
|
||||
}
|
||||
|
||||
.b06-structure-panel__btn.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -281,6 +281,10 @@ export const ui_locales_b2 = {
|
||||
B06_Cross_Mat_Concrete: ["콘크리트", "Concrete"],
|
||||
B06_Cross_Extra_Count: ["추가 기슭막이(단)", "Extra revetments"],
|
||||
B06_Cross_Extra_Add: ["한 단 추가", "Add one tier"],
|
||||
B06_Cross_Extra_Equalize: [
|
||||
"다단 등간격 배치 — 사면 구간을 같게",
|
||||
"Distribute tiers evenly along the slope",
|
||||
],
|
||||
B06_Cross_Extra_Remove: ["한 단 삭제", "Remove one tier"],
|
||||
B06_Cross_Extra_Limit: [
|
||||
"지형상 추가 기슭막이는 {n}단까지만 가능합니다 — 벽이 원지반에 0.5m 이상 묻히면 성토가 필요 없습니다",
|
||||
|
||||
Reference in New Issue
Block a user