feat(B06): 기슭막이 높이·자리 체계 개편 + 조정창 방향키 조작

- 조정창 십자 키패드 키보드 방향키 조작(집수정 9키는 십자 축만)
- 배관 기슭막이 높이 = 순수 높이(바닥~상단), 하한·기본 = 관경+1.0m (고정 2.0m 폐기)
- 매몰 시 전면 발끝 지반 추적 바닥 심화 삭제 — 바닥 = invert-0.5 고정
- 상하(d) 0점 = 성토선 0 지점(벽 상단=노견), 기본 자리 = 성토사면 경사길이 4m 지점
  (d=null 자동, 저장·표시·정수맞춤 pinned 판정 연동, 구세션 d=0 호환)
- 유입 접속 절토선: 지반 교차 없으면 1m 공간 확보에서 끝
- 자동 집수정 기본 형상 L형 -> I형 (선택지 밖 구조물 그려지던 불일치)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 20:34:30 +09:00
co-authored by Claude Fable 5
parent 7217747e81
commit 5371534fc3
11 changed files with 209 additions and 126 deletions
+5 -2
View File
@@ -136,7 +136,7 @@ export function appendCulvertOverlay(
planLine(
layout.inletFill.points,
layout.inletFill.kind === "cut"
? `유입부 접속 절토선 — 관 하단에서 3도(계류 쪽 오름) 1m 후 표준 절토 경사로 원지반까지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`
? `유입부 접속선 — 관 하단에서 3도(계류 쪽 오름) 1m 공간 확보에서 끝 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`
: `유입부 3도 성토선(계류 쪽 오름 — 물이 관으로) — 원지반 교차에서 정지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`,
);
}
@@ -168,7 +168,10 @@ export function appendCulvertOverlay(
const revetShape = polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-revet",
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${wall.height.toFixed(1)}m` +
// 배관 벽 높이 표기 = 순수 높이(바닥~상단, 관 아래 0.5 포함 — 2026-08-23 사용자).
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
wall.height + (wall.role === "extra" ? 0 : REVET_EMBED_DEPTH_M)
).toFixed(1)}m` +
`(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` +
`, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` +
(wall.floatGapM > 0.01
@@ -36,7 +36,9 @@ export function resolveBasinChoice(
const selected = choice === "I" || choice === "L" || choice === "U";
return {
reason: choice === "revet" ? null : selected ? (ruleReason ?? "manual") : ruleReason,
shape: selected ? choice : "L",
// 자동(기본) 집수정은 **I형**(2026-08-23 사용자) — 종전 L형 기본은 ㄴ·ㄷ형이
// 선택지에서 숨는 지형(basinLUAllowed=false)에서 선택지 밖 구조물이 그려졌다.
shape: selected ? choice : "I",
};
}
@@ -89,8 +89,9 @@ export function revetHeightLimit(form: string | null | undefined): number {
/* ── 기슭막이 재질·높이 조작(2026-08-22 사용자 확정) ─────────────────────
* 높이 조작이 교본 형태별 한계와 부딪히면 **재질을 바꿔야** 더 올릴 수 있다:
* 메쌓기 2.0 / 찰쌓기 3.0(돌쌓기.md §1) / 콘크리트 5.0(프로젝트 기본값,
* 외부 기준 미확인). 높이 기준은 근입
* 0.5m 위 기준선(관 invert 자리)~상단의 **계산용 높이**로 종전과 동일. */
* 외부 기준 미확인). 배관 기슭막이의 높이 제어·표기·한계 기준은 **순수 높이**
* (벽 바닥~상단, 관 아래 0.5m 포함 — 2026-08-23 사용자 확정). 일반(추가)
* 기슭막이는 종전 계산용 높이(근입 0.5 위 기준선~상단) 기준을 유지한다. */
export type RevetMaterial = "dry" | "wet" | "concrete";
export const REVET_MATERIALS: RevetMaterial[] = ["dry", "wet", "concrete"];
@@ -114,10 +115,15 @@ export function materialFromForm(form: string | null | undefined): RevetMaterial
* 따른다: 유입측은 계류 쪽이 높고(물이 관으로), 유출측은 계류 쪽이 낮다(관에서 밖으로). */
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 사용자 정정). */
export const PIPE_WALL_DEFAULT_HEIGHT_M = 2.0;
/**
* 배관 기슭막이 관 하부 여유(m) — 관 invert 아래 벽 바닥까지의 깊이(2026-08-23
* 사용자 확정: 배관 기준 아래 0.5·위 0.5 여유). 값은 근입 깊이와 같은 0.5라
* 형상은 종전과 동일하고, **순수 높이 하한 = 관경 + 위 0.5 + 아래 0.5**가 된다.
* 종전 고정 하한·기본 2.0m(관경 무관)는 폐기.
*/
export function pipeWallMinPureHeight(diameterM: number): number {
return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;
}
/** 일반(추가) 기슭막이 높이 조작 하한(m) — 프로젝트 기본값 0.5m. */
export const EXTRA_WALL_MIN_HEIGHT_M = 0.5;
/** 일반(추가) 기슭막이 기본 높이(m). */
@@ -136,6 +142,17 @@ export const FILL_SLOPE_RATIO_MAX = 2.0;
/** 성토사면 길이 한계(m) — `성토_비탈면.md` §2 "5m 이내". 초과 시 옹벽·석축 등 의무. */
export const FILL_SLOPE_MAX_LENGTH_M = 5.0;
/**
* 배관 기슭막이 기본 자리 — 노견에서 **성토사면 경사길이 4m 지점**(2026-08-23 사용자
* 확정). 상하(d) 조작의 0점은 성토선 길이 0 지점(벽 상단 = 노견)이고, d는 그 점에서
* 1:1.2 성토선을 타고 내려간 수평 성분(m)이다. 기본 자리가 안 되는 지형(매몰·역경사·
* 토피)은 배치 풀이의 기존 한계 논리가 안쪽으로 되돌린다.
*/
export const PIPE_WALL_DEFAULT_SLOPE_M = 4.0;
/** 기본 자리(경사길이 4m)의 수평 성분(m) — d 기본값. */
export const PIPE_WALL_DEFAULT_RUN_M =
(PIPE_WALL_DEFAULT_SLOPE_M * FILL_SLOPE_RATIO_MIN) / Math.hypot(1, FILL_SLOPE_RATIO_MIN);
/**
* 성토 전체고(노견 − 원지반) 기준(m) — 이 높이를 넘으면 1:1.2로도 사면이 5m를 넘어
* 구조물이 필요해진다(3m × √(1+1.2²) = 4.69m, 3.2m에서 5.0m). 기슭막이를 자연 사면
@@ -85,6 +85,7 @@ export function slopedCrossing(
* 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②).
* · 관 하단점이 원지반보다 **위**: 0도 성토선 — 지반과 만나는 곳에서 정지.
* · **아래**: 0도 직선 1m 후 표준 절토 경사(1:n)로 지반까지 올라가는 절토선.
* 지반 교차가 안 나오는 지형은 1m 공간 확보에서 끝(2026-08-23 사용자).
*/
export function inletGroundConnector(
corner: OffsetPoint,
@@ -125,13 +126,15 @@ export function inletGroundConnector(
};
}
if (corner.elevation < ground - 0.02) {
// 3도 직선 1m(계류 쪽이 높다) 후 표준 절토 경사.
// 3도 직선 1m(계류 쪽이 높다) 후 표준 절토 경사로 지반까지. 교차가 안
// 나오면 절토선 없이 **1m 공간 확보에서 끝**(2026-08-23 사용자 — 종전에는
// 샘플 한계까지 올라가는 긴 선이 남았다).
const bend: OffsetPoint = {
offset: corner.offset + outward * 1.0,
elevation: corner.elevation + PIPE_CONNECT_GRADE * 1.0,
};
let last = bend;
const cutSpan = (limitOffset - bend.offset) * outward;
let cross: OffsetPoint | null = null;
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;
@@ -140,18 +143,19 @@ export function inletGroundConnector(
if (gap <= 0) {
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const tCross = t - 0.25 * (1 - fraction);
last = {
cross = {
offset: bend.offset + outward * tCross,
elevation: bend.elevation + tCross / Math.max(cutSlopeRatio, 0.1),
};
break;
}
last = { offset: x, elevation };
previousGap = gap;
}
return {
points: [corner, bend, last],
lengthM: 1 + Math.hypot(last.offset - bend.offset, last.elevation - bend.elevation),
points: cross ? [corner, bend, cross] : [corner, bend],
lengthM: cross
? 1 + Math.hypot(cross.offset - bend.offset, cross.elevation - bend.elevation)
: 1,
overLimit: false,
kind: "cut",
};
@@ -217,7 +221,6 @@ function buildExtraWall(
height: number,
material: RevetMaterial,
floatGapM: number,
groundAt: (offset: number) => number,
): WallLayout {
const thickness = REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
@@ -228,16 +231,10 @@ function buildExtraWall(
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
// 하단 = 기준선(anchor) 아래 근입 0.5m — 근입은 **경사선(전면) 측 깊이** 기준이라
// 전면 발끝 지반이 낮으면 그 아래 0.5까지 더 내린다(2026-08-22 사용자 ②).
let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
if (floatGapM <= 1e-6) {
for (let pass = 0; pass < 6; pass += 1) {
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break;
bottomElevation = toeGround - REVET_EMBED_DEPTH_M;
}
}
// 하단 = 기준선(anchor) 아래 근입 0.5m **고정** — 원지반에 묻혀도 바닥을 더
// 내리지 않는다. 벽 형상은 높이값으로만 정한다(2026-08-23 사용자 확정 —
// 종전 전면 발끝 지반 추적 수렴 삭제).
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
offset: frontXAt(bottomElevation),
@@ -342,7 +339,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
// 등간격 배치: 1차는 남은 구간(끝 포함) 균등 분할 근사, 이후 라운드는 보정 d.
const requestedD =
plan === "user"
? adjust.d
? (adjust.d ?? 0)
: plan === "greedy"
? (trailing.lengthM / (input.adjusts.length - i + 1)) *
(FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN)) -
@@ -350,24 +347,9 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
: (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;
};
/** 후보 자리의 바닥 — buildExtraWall과 같은 근입 0.5m 고정(2026-08-23). */
const bottomAt = (_anchorX: number, baseElevation: number): number =>
baseElevation - REVET_EMBED_DEPTH_M;
// 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이
// 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이
// 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리).
@@ -426,7 +408,6 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
height,
material,
Math.max(0, base - groundAt(anchorX)),
groundAt,
);
walls.push(wall);
// 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부.
@@ -443,7 +424,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN,
overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
});
// 다음 단 시작점 = 이 벽 **실제 바닥**(근입 심화 반영) +0.5m 수평선과 전면
// 다음 단 시작점 = 이 벽 바닥 +0.5m(= 기준선) 수평선과 전면
// 경사선(1:0.3)의 교차점(2026-08-22 사용자 — 관 끝점 규칙과 동일 기준).
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
src = {
@@ -493,7 +474,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
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,
(applied, i) => (applied.d ?? 0) + (mean - (lengths[i] ?? mean)) * horizontalPerSlope,
);
result = cascadeOnce(dNext);
}
@@ -9,12 +9,11 @@
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
import {
BASIN_MAX_FILL_SLOPE_M,
FILL_MIN_RISE_M,
materialFromForm,
materialLabel,
materialLimit,
PIPE_WALL_DEFAULT_HEIGHT_M,
PIPE_WALL_MIN_HEIGHT_M,
PIPE_WALL_DEFAULT_RUN_M,
pipeWallMinPureHeight,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
@@ -22,6 +21,7 @@ import {
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
REVET_TRAP_TOP_M,
revetHeightLimit,
revetTargetHeight,
} from "./B06_Section_UI_Cross_Culvert_Const";
@@ -55,7 +55,7 @@ import { buildExtrasAt, inletGroundConnector } from "./B06_Section_UI_Cross_Culv
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
import {
cutLength,
minShoulderWallOffset,
fillWallBaseWidth,
outletSlopeFactory,
fillSlopeOf,
groundInterpolator,
@@ -162,20 +162,19 @@ export function computeCulvertLayout(
inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge));
}
// 벽 제원(4축): 재질이 높이 한계(메2/찰3/콘5)를 정하고, 높이 = 설정 ?? 기본 2.0.
// 하한 = max(2.0, 관경+여유고) — 관이 벽 위로 삐져나오면 안 된다.
// 벽 제원(4축): 재질이 높이 한계(메2/찰3/콘5)를 정한다. 높이 기준 = **순수 높이**
// (바닥~상단 — 2026-08-23 사용자). 하한·기본 = 관경 + 아래 0.5 + 위 0.5 —
// 관이 벽 위로 삐져나오거나 바닥에 붙으면 안 된다. 기하는 종전대로
// invert~상단 계산용 높이(= 순수 − 0.5)로 돈다.
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
const adjInlet = adjustOf(revetShift?.inlet);
const adjOutlet = adjustOf(revetShift?.outlet);
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
const material = adjust.m ?? materialFromForm(spec.revet_form);
const limit = materialLimit(material);
const floor = Math.max(PIPE_WALL_MIN_HEIGHT_M, revetTargetHeight(diameter));
const height = Math.min(
Math.max(adjust.h ?? Math.min(PIPE_WALL_DEFAULT_HEIGHT_M, limit), floor),
Math.max(limit, floor),
);
return { material, limit, height };
const floor = pipeWallMinPureHeight(diameter);
const pureHeight = Math.min(Math.max(adjust.h ?? floor, floor), Math.max(limit, floor));
return { material, limit, pureHeight, height: pureHeight - REVET_EMBED_DEPTH_M };
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
@@ -191,33 +190,38 @@ export function computeCulvertLayout(
diameterM: diameter,
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
});
/** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다. */
/** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다(높이는 순수 높이 기준). */
const appliedAdjust = {
inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.height : null },
outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.height : null },
inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.pureHeight : null },
outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.pureHeight : null },
};
/** 손으로 민 벽의 수직 제원(높이 증가·바닥 띄움) — buildWall에 물려 준다. */
const wallVertical: { inlet: WallVertical | null; outlet: WallVertical | null } = {
inlet: null,
outlet: null,
};
// 자동 자리 = **노폭 연장 최소 지점**(1:1.2가 딱 성립하는 가장 안쪽). 없으면 사면 끝.
let inletAutoOffset: number | null = null;
// d의 0점 = **성토선 길이 0 지점**(벽 상단 = 노견 — 2026-08-23 사용자 확정).
// 그 자리(하단선 중점)는 이음선 상단점이 노견 끝에 오는 offset이고, 기본 자리는
// 성토선을 타고 경사길이 4m 내려간 지점(PIPE_WALL_DEFAULT_RUN_M)이다.
const slopeZeroAnchor = (
edge: { offset_m: number },
outward: number,
wallHeight: number,
): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight) / 2 - REVET_TRAP_TOP_M);
if (!basinReason && designAt) {
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용.
inletAutoOffset = inletOptions.revetAutoOffset;
// 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라
// 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard).
const placed = placeInletWall({
autoOffset: inletAutoOffset,
autoOffset: slopeZeroAnchor(inletInfo.edge, inletInfo.outward, inletWallSpec.height),
outward: inletInfo.outward,
height: inletWallSpec.height,
baseElevation0: inletBaseAt(inletAutoOffset),
baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height,
edgeElevation: inletInfo.edge.elevation_m,
invertCap: invertCap(inletInfo.edge),
adjust: adjInlet,
defaultD: PIPE_WALL_DEFAULT_RUN_M,
groundAt,
limitOffset: inletInfo.limit,
outletGuard: {
@@ -340,19 +344,12 @@ export function computeCulvertLayout(
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
// 상단 변: 사다리꼴 상단(t/2) + 띠(t). 이음선은 상단 변 중간점에서 1:0.3으로 바닥까지.
const topFront = topJoint + outward * thickness;
// 하단선 = **수평 기초**, 깊이 = 기준선 아래 근입 0.5m(전면 측 기준으로 수렴).
// 하단선 = **수평 기초**, 깊이 = 기준선(관 invert) 아래 근입 0.5m 고정 —
// 원지반에 묻혀도 바닥을 더 내리지 않는다. 벽 형상은 높이값으로만 정한다
// (2026-08-23 사용자 확정 — 종전 전면 발끝 지반 추적 수렴 삭제).
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
if (floatGapM <= 1e-6) {
// 근입 0.5m는 **경사선(전면) 측 깊이** 기준(2026-08-22 사용자 ②) — 전면 발끝
// 지반이 더 낮으면 그 아래 0.5까지 내린다. 내려가면 발끝이 더 나가므로 수렴 반복.
for (let pass = 0; pass < 6; pass += 1) {
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break;
bottomElevation = toeGround - REVET_EMBED_DEPTH_M;
}
}
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
offset: frontXAt(bottomElevation),
@@ -407,29 +404,30 @@ export function computeCulvertLayout(
);
// 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21).
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
// 유출 벽 자동 자리도 **노견 최소 지점**(2026-08-22 사용자 확정) — 못 찾으면 사면 끝.
let outletWallAnchor = outletAnchor;
const outletFeasible = designAt
? minShoulderWallOffset(
outletInfo.edge,
outletInfo.outward,
outletWallSpec.height,
invertAt,
outletAnchorOffset,
outletInfo.limit,
)
: null;
const outletAutoOffset = outletFeasible ?? outletAnchorOffset;
// 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 경사길이 4m 지점. 관 길이
// 정수 맞춤 루프가 벽을 옮길 때 invert가 같은 성토선을 타도록 함수로 둔다.
const outletZeroAnchor = slopeZeroAnchor(
outletInfo.edge,
outletInfo.outward,
outletWallSpec.height,
);
const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height;
const outletLineInvertAt = (offset: number): number =>
designAt
? outletZeroInvert - ((offset - outletZeroAnchor) * outletInfo.outward) / FILL_SLOPE_RATIO_MIN
: invertAt(offset);
if (designAt) {
// 4축 배치(유입과 동일 풀이) + 유출 전용: 매몰-무교차 자리 금지(2026-08-22 ③).
const placed = placePipeWall({
autoOffset: outletAutoOffset,
autoOffset: outletZeroAnchor,
outward: outletInfo.outward,
height: outletWallSpec.height,
baseElevation0: invertAt(outletAutoOffset),
baseElevation0: outletZeroInvert,
edgeElevation: outletInfo.edge.elevation_m,
invertCap: inlet.elevation,
adjust: adjOutlet,
defaultD: PIPE_WALL_DEFAULT_RUN_M,
groundAt,
limitOffset: outletInfo.limit,
requireCrossing: true,
@@ -457,8 +455,7 @@ export function computeCulvertLayout(
/** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의
* 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */
const outletPipeEnd = (wall: WallLayout): OffsetPoint => {
// 기준은 **실제 바닥**(전면 근입으로 깊어진 값) +0.5 — 기준선(anchor)을 쓰면
// 가파른 지반에서 바닥만 내려가고 관이 벽 위쪽에 떠 보인다(2026-08-22 사용자 ②).
// 기준 = 바닥 +0.5(= 관 invert 자리 — 2026-08-23부터 바닥은 invert0.5 고정).
const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
return {
offset:
@@ -494,11 +491,13 @@ export function computeCulvertLayout(
// ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출
// 벽 자리를 관 축 방향 바깥으로 민다(벽 두께 0.45 고정).
const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, outletWallSpec.height);
// 정수 맞춤이 옮길 후보 자리도 같은 성토선을 탄다(2026-08-23 — 0점 기준 통일).
const outletSlopeAt = outletSlopeFactory(outletInfo, outletLineInvertAt, outletWallSpec.height);
/** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */
const outletThickness = REVET_THICKNESS_M;
// 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다.
const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || Math.abs(adjOutlet.d) > 1e-9;
// d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값).
const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null;
if (outletWall && !outletPinned) {
const target = Math.ceil(cutLength(pipeCorners) - 1e-6);
const rebuild = (): void => {
@@ -535,21 +534,22 @@ export function computeCulvertLayout(
// ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위).
const moved = outletWallAnchor.offset + pipeAxis.axis.offset * gap;
const movedSlope = outletSlopeAt(moved, outletThickness);
// 사면 역전·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저다.
// 사면 역전(벽 상단이 노견 위)·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다
// 도면 성립이 먼저다. 성토고 0(성토선 0점)까지는 허용(2026-08-23 사용자).
if (
(moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 ||
outletInfo.edge.elevation_m - (invertAt(moved) + outletWallSpec.height) < FILL_MIN_RISE_M
outletInfo.edge.elevation_m - (outletLineInvertAt(moved) + outletWallSpec.height) < 0
) {
break;
}
// 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 —
// 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적).
if (
movedSlope.ratio >= FILL_SLOPE_RATIO_MIN &&
movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 &&
movedSlope.ratio <= FILL_SLOPE_RATIO_MAX &&
movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6
) {
outletWallAnchor = { offset: moved, elevation: invertAt(moved) };
outletWallAnchor = { offset: moved, elevation: outletLineInvertAt(moved) };
rebuild();
if (!outletWall) break;
continue;
@@ -573,7 +573,8 @@ export function computeCulvertLayout(
if (basin) {
applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt);
}
// 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위 0도 성토선 / 아래 0도 1m+절토선.
// 유입 기슭막이의 관 시작 접속선 — 위 0도 성토선 / 아래 0도 1m 공간 확보에서 끝
// (2026-08-23 사용자 — 절토 경사 연장 삭제).
const inletFill = !basin
? inletGroundConnector(
pipeCorners.inlet.bottom,
@@ -363,31 +363,36 @@ export interface PipeWallPlacement {
/**
* 배관 기슭막이 4축 배치(2026-08-22 확정 규칙 묶음) — 유입·유출 공용.
* · x = 노견 연장 평행이동(안쪽 금지), d = 성토선 대각(위 한계 = 최소 성토고·
* invert 상한(토피/역경사)).
* · 기준(2026-08-23 사용자 확정): autoOffset·baseElevation0 = **성토선 길이 0
* 지점**(벽 상단 = 노견)의 자리·invert. d는 그 점 기준 절대값(null = 기본
* defaultD, 경사길이 4m 지점), 위 한계 = 성토선 0 지점(d ≥ 0)·invert 상한.
* · x = 노견 연장 평행이동(안쪽 금지).
* · requireCrossing(유출): 매몰인데 0도 절토선이 원지반과 다시 못 만나는 자리는
* 금지 — 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
* 금지 — 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
*/
export function placePipeWall(input: {
/** 성토선 0 지점(벽 상단 = 노견)의 벽 자리(하단선 중점 offset). */
autoOffset: number;
outward: number;
height: number;
/** 자동 자리의 invert(= baseAt(autoOffset)). */
/** 성토선 0 지점의 invert(= 노견 표고 높이). */
baseElevation0: number;
edgeElevation: number;
/** invert 상한 — 유입: 노면−관경−토피, 유출: 유입 invert(역경사 방지). */
invertCap: number;
adjust: WallAdjust;
/** d 미지정(null) 시의 기본 자리 — 성토사면 경사길이 4m 지점의 수평 성분. */
defaultD: number;
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.edgeElevation - top0),
-FILL_SLOPE_RATIO_MIN * (input.invertCap - (top0 - input.height)),
);
let d = Math.max(input.adjust.d, Math.ceil(dLow * 10) / 10);
let d = Math.max(input.adjust.d ?? input.defaultD, Math.ceil(dLow * 10) / 10);
let x = Math.max(input.adjust.x, 0);
if (input.requireCrossing) {
const placeable = (anchorOffset: number, invertE: number): boolean =>
@@ -471,6 +476,7 @@ export function placeInletWall(input: {
edgeElevation: number;
invertCap: number;
adjust: WallAdjust;
defaultD: number;
groundAt: (offset: number) => number;
limitOffset: number;
/** 유출 쪽 수용 검사 인자 — 없으면 가드 없이 배치한다. */
@@ -12,19 +12,23 @@ import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
* x = 좌우(m, + = 계류측 바깥) — 노견(또는 윗단 시작점 수평선)을 늘려 평행이동,
* 성토선 물매 1:1.2 불변.
* d = 상하(m, 수평 성분, + = 사면 아래) — 성토선을 타고 대각 이동.
* h = 높이(m, 계산용: 근입 0.5 위 기준선~상단). null = 기본값
* (배관 2.5·일반 1.5, 재질 한계로 잘림).
* **배관(유입·유출) 벽**은 0점 = 성토선 길이 0 지점(벽 상단 = 노견)인
* 절대값(2026-08-23 사용자 확정), null = 기본 자리(경사길이 4m 지점).
* 추가 벽은 종전대로 자동 자리 기준 상대값(null = 0).
* h = 높이(m). 배관 벽은 **순수 높이**(바닥~상단, 관 아래 0.5 포함 —
* 2026-08-23), 추가 벽은 계산용(근입 0.5 위 기준선~상단). null = 기본값
* (배관 관경+1.0·일반 1.5, 재질 한계로 잘림).
* m = 재질. null = 기본값(메쌓기). 한계: 메 2.0 / 찰 3.0 / 콘크리트 5.0.
*/
export interface WallAdjust {
x: number;
d: number;
d: number | null;
h: number | null;
m: RevetMaterial | null;
}
/** 조작값 기본(전부 자동). */
export const ZERO_ADJUST: WallAdjust = { x: 0, d: 0, h: null, m: null };
export const ZERO_ADJUST: WallAdjust = { x: 0, d: null, h: null, m: null };
export interface OffsetPoint {
offset: number;
@@ -149,7 +149,7 @@ export function computeCardCulvert(
"info",
);
}
if (Math.abs(requested.d - applied.d) > 0.05) {
if (requested.d != null && applied.d != null && Math.abs(requested.d - applied.d) > 0.05) {
showToast(
L(requested.d < applied.d ? "B06_Cross_Revet_Limit_Up" : "B06_Cross_Revet_Limit_Down"),
"info",
@@ -170,7 +170,12 @@ export function computeCardCulvert(
showToast(L("B06_Cross_Height_Floor"), "info");
}
}
revetOffset.syncApplied(section.chainage_m, role, applied);
// d = null(기본 자리)은 null로 되돌려 담는다 — 적용 좌표를 그대로 저장하면
// 자동 자리가 "사용자 지정"으로 굳어 지형·높이 변경을 따라가지 못한다.
revetOffset.syncApplied(section.chainage_m, role, {
...applied,
d: requested.d == null ? null : applied.d,
});
}
}
return layout;
@@ -337,6 +337,38 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
);
extraParts.controls.append(countMinus, countValue, countPlus);
// 키보드 방향키 = 십자 버튼(2026-08-23 사용자). 9키(집수정)도 **십자 축만** —
// 모서리(크기) 키는 대상 아님. 셀렉트 포커스 중엔 드롭다운 탐색에 양보한다.
// 창은 카드마다 새로 만들어지므로 문서 리스너는 show/hide에 맞춰 붙였다 떼고,
// 카드가 통째로 사라진 경우(root 분리)엔 스스로 해제한다.
const ARROW_SLOT: Record<string, string> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
const onArrowKey = (event: KeyboardEvent) => {
if (!root.isConnected) {
document.removeEventListener("keydown", onArrowKey);
return;
}
const slot = ARROW_SLOT[event.key];
if (!slot || root.classList.contains("is-hidden")) return;
const focus = event.target as HTMLElement | null;
if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return;
for (const candidate of [basinRow, moveRow]) {
if (candidate.row.classList.contains("is-hidden")) continue;
const button = candidate.controls.querySelector<HTMLButtonElement>(
`.b06-structure-panel__btn--${slot}`,
);
if (button) {
event.preventDefault();
button.click();
return;
}
}
};
const closeButton = makeButton("✕", L("B06_Cross_Revet_Close"), () => deps.close());
closeButton.classList.add("b06-structure-panel__close");
@@ -362,7 +394,9 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
show: (key) => {
current = key;
root.classList.toggle("is-hidden", key === null);
document.removeEventListener("keydown", onArrowKey);
if (!key) return;
document.addEventListener("keydown", onArrowKey);
const isExtra = key.startsWith("extra");
const movable = deps.canNudge(key);
const structure = deps.structureFor();
@@ -431,11 +465,15 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
(Math.abs(adjust.x) > 1e-9
? `${L(adjust.x > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward")} ${Math.abs(adjust.x).toFixed(1)}m`
: L("B06_Cross_Revet_Auto"));
// d = null → 자동(기본 자리). 숫자는 성토선 0점(벽 상단=노견) 기준 절대값
// (배관 벽 — 2026-08-23). 0 = 성토선 0 지점.
slopeLine.textContent =
`${L("B06_Cross_Slope_Label")} ` +
(Math.abs(adjust.d) > 1e-9
? `${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m`
: L("B06_Cross_Revet_Auto"));
(adjust.d == null
? L("B06_Cross_Revet_Auto")
: Math.abs(adjust.d) > 1e-9
? `${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m`
: "0m");
},
};
}
+25 -4
View File
@@ -37,6 +37,7 @@ import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_Vie
import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes";
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import {
@@ -164,8 +165,11 @@ export function createCrossSectionCard(
let culvertExtraState = { canAdd: false, count: 0 };
/** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */
let culvertBasinExtraState = { canAdd: false, count: 0 };
/** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준. */
/** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준.
* 배관(유입·유출) 벽 높이는 **순수 높이**(바닥~상단 — 2026-08-23 사용자). */
let culvertWallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>();
/** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */
let culvertAppliedD = new Map<RevetKey, number>();
/**
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
* 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도
@@ -417,10 +421,23 @@ export function createCrossSectionCard(
};
culvertWallSpecs = new Map();
const specOf = (wall: (typeof culvertLayout.walls)[number], key: RevetKey) =>
culvertWallSpecs.set(key, { height: wall.height, material: wall.material });
culvertWallSpecs.set(key, {
// 배관 벽은 순수 높이(바닥~상단 = 계산용 + 근입 0.5 — 2026-08-23 사용자).
height: wall.role === "extra" ? wall.height : wall.height + REVET_EMBED_DEPTH_M,
material: wall.material,
});
for (const wall of culvertLayout.walls) specOf(wall, wall.role as RevetKey);
culvertLayout.extraWalls.forEach((wall, i) => specOf(wall, `extra${i}` as RevetKey));
culvertLayout.basinExtras.forEach((wall, i) => specOf(wall, `bextra${i}` as RevetKey));
culvertAppliedD = new Map();
culvertAppliedD.set("inlet", culvertLayout.revetShift.inlet.d ?? 0);
culvertAppliedD.set("outlet", culvertLayout.revetShift.outlet.d ?? 0);
culvertLayout.revetShift.extras.forEach((applied, i) =>
culvertAppliedD.set(`extra${i}` as RevetKey, applied.d ?? 0),
);
culvertLayout.revetShift.basinExtras.forEach((applied, i) =>
culvertAppliedD.set(`bextra${i}` as RevetKey, applied.d ?? 0),
);
}
if (culvertLayout) {
setRevetActive = appendCulvertOverlay(
@@ -534,15 +551,19 @@ export function createCrossSectionCard(
const materialOfWall = (key: RevetKey): RevetMaterial =>
culvertWallSpecs.get(key)?.material ?? "dry";
const adjustOf = (key: RevetKey): WallAdjust =>
revetOffset?.adjustFor(section, key) ?? { x: 0, d: 0, h: null, m: null };
revetOffset?.adjustFor(section, key) ?? { x: 0, d: null, h: null, m: null };
const panel = buildStructurePanel({
adjustFor: adjustOf,
nudge: (key, screenDeltaM) =>
revetOffset?.update(section.chainage_m, key, {
x: adjustOf(key).x + screenDeltaM * outwardOf(key),
}),
// d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점
// 기준 절대값이라 시작값이 필요하다).
nudgeSlope: (key, deltaM) =>
revetOffset?.update(section.chainage_m, key, { d: adjustOf(key).d + deltaM }),
revetOffset?.update(section.chainage_m, key, {
d: (adjustOf(key).d ?? culvertAppliedD.get(key) ?? 0) + deltaM,
}),
nudgeHeight: (key, deltaM) => {
const current = adjustOf(key);
const base = current.h ?? heightOfWall(key);
@@ -135,7 +135,9 @@ export function createStationControls(deps: StationControlDeps): StationControls
if (typeof value === "number" && Number.isFinite(value)) {
revetShifts.set(entry, { ...ZERO_ADJUST, x: value });
} else if (value && typeof value === "object") {
revetShifts.set(entry, { ...ZERO_ADJUST, ...value });
// 구세션 호환: d=0은 옛 "자동 자리" 의미 — 새 체계(절대 0 = 성토선 0점)로
// 읽으면 벽이 노견까지 튀므로 null(기본 자리)로 옮긴다.
revetShifts.set(entry, { ...ZERO_ADJUST, ...value, d: value.d ? value.d : null });
}
});
} catch {
@@ -157,15 +159,17 @@ export function createStationControls(deps: StationControlDeps): StationControls
const round1 = (value: number): number => Math.round(value * 10) / 10;
const clampMove = (value: number): number => Math.min(Math.max(round1(value), -10), 10);
const isDefaultAdjust = (value: WallAdjust): boolean =>
Math.abs(value.x) < 1e-9 && Math.abs(value.d) < 1e-9 && value.h == null && value.m == null;
Math.abs(value.x) < 1e-9 && value.d == null && value.h == null && value.m == null;
const storeAdjust = (key: string, value: WallAdjust): void => {
if (isDefaultAdjust(value)) revetShifts.delete(key);
else revetShifts.set(key, value);
persistRevetShifts();
};
const axisEq = (a: number | null, b: number | null): boolean =>
a == null || b == null ? a === b : Math.abs(a - b) < 1e-9;
const sameAdjust = (a: WallAdjust, b: WallAdjust): boolean =>
Math.abs(a.x - b.x) < 1e-9 &&
Math.abs(a.d - b.d) < 1e-9 &&
axisEq(a.d, b.d) &&
(a.h ?? null) === (b.h ?? null) &&
(a.m ?? null) === (b.m ?? null);
@@ -189,7 +193,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
const current = revetShifts.get(key) ?? { ...ZERO_ADJUST };
const next: WallAdjust = {
x: clampMove(patch.x ?? current.x),
d: clampMove(patch.d ?? current.d),
// d: null = 기본 자리(배관 벽 — 경사길이 4m 지점) 유지.
d: patch.d === undefined ? current.d : patch.d === null ? null : clampMove(patch.d),
// 높이는 0.1 눈금 반올림만 — 재질 한계 절삭은 기하가 하고 되받는다.
h: patch.h === undefined ? current.h : patch.h === null ? null : round1(patch.h),
m: patch.m === undefined ? current.m : patch.m,