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:
2026-08-22 15:29:43 +09:00
co-authored by Claude Opus 5
parent 63bd922711
commit de78feabde
11 changed files with 483 additions and 236 deletions
+44 -22
View File
@@ -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)`,
);
}
};
+275 -149
View File
@@ -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 사용자 ④ — 높이 행 **위**, 폭 축소: 한계는 툴팁으로).
+1
View File
@@ -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;
+5 -1
View File
@@ -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;
}
+4
View File
@@ -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 이상 묻히면 성토가 필요 없습니다",