feat(B06): 기슭막이 4축 조작 — 좌우(노견 연장)·상하(사면 대각)·높이·재질

- 좌우 ◀▶ = 노견(추가 벽은 윗단 0.5m 기준선) 수평 연장 평행이동 —
  성토선 물매 1:1.2·상단 표고 불변, 지반에 못 닿으면 바닥 뜸 허용
- 상하 ▲▼ = 성토선을 타는 대각 이동(수평 성분 1m), 위 한계 =
  최소 성토고·토피(역경사), 추가 벽은 윗단 하단 관통 금지
- 높이 ±0.1m: 배관 벽 2.0~재질한계(기본 2.5, 메쌓기면 2.0로 절삭),
  일반 벽 0.5~재질한계(기본 1.5). 높이 기준 = 근입 0.5 위 기준선~상단
- 재질 선택(메쌓기 2.0/찰쌓기 3.0/콘크리트 5.0m) — 한계 초과 시
  토스트로 재질 변경 안내, 자동 높이는 기본값 고정(사용자 확정)
- 다단 단 수: 숫자 입력 + +/- 버튼 병행, 입력기 스피너 제거
- 저장 스키마: revetx 값이 {x,d,h,m} 객체로 확장(구 숫자 형식 호환)
- 인터페이스 3종을 Wire로 이전(Cross_View 700줄 제한)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 14:21:30 +09:00
co-authored by Claude Opus 5
parent 8167dd23c3
commit ff3e3b734b
10 changed files with 597 additions and 294 deletions
@@ -86,6 +86,39 @@ export function revetHeightLimit(form: string | null | undefined): number {
return 3.0;
}
/* ── 기슭막이 재질·높이 조작(2026-08-22 사용자 확정) ─────────────────────
* 높이 조작이 교본 형태별 한계와 부딪히면 **재질을 바꿔야** 더 올릴 수 있다:
* 메쌓기 2.0 / 찰쌓기 3.0(돌쌓기.md §1) / 콘크리트 5.0(임시 — 사용자 지정
* "일단 5m", 흙막이.md 산복기초 4.0과 다름·확정 시 교체). 높이 기준은 근입
* 0.5m 위 기준선(관 invert 자리)~상단의 **계산용 높이**로 종전과 동일. */
export type RevetMaterial = "dry" | "wet" | "concrete";
export const REVET_MATERIALS: RevetMaterial[] = ["dry", "wet", "concrete"];
export function materialLimit(material: RevetMaterial): number {
return material === "dry" ? 2.0 : material === "wet" ? 3.0 : 5.0;
}
export function materialLabel(material: RevetMaterial): string {
return material === "dry" ? "메쌓기" : material === "wet" ? "찰쌓기" : "콘크리트";
}
/** 백엔드 spec 형식 문자열("돌쌓기(메)" 등) → 재질 기본값. 기본은 메쌓기. */
export function materialFromForm(form: string | null | undefined): RevetMaterial {
if (form?.includes("찰")) return "wet";
if (form?.includes("콘크리트")) return "concrete";
return "dry";
}
/** 배관 기슭막이 높이 조작 하한(m) — 2~3m 구간 지시(2026-08-22)의 하한. */
export const PIPE_WALL_MIN_HEIGHT_M = 2.0;
/** 배관 기슭막이 기본 높이(m, 2026-08-22 지시 2.5) — 재질 한계로 잘린다(메→2.0). */
export const PIPE_WALL_DEFAULT_HEIGHT_M = 2.5;
/** 일반(추가) 기슭막이 높이 조작 하한(m). */
export const EXTRA_WALL_MIN_HEIGHT_M = 0.5;
/** 일반(추가) 기슭막이 기본 높이(m). */
export const EXTRA_WALL_DEFAULT_HEIGHT_M = 1.5;
/**
* 성토 비탈 기울기(1:n) 허용 범위 — 지식DB `성토_비탈면.md` §1:
* "**1:1.2~2.0** 범위에서 토질·용수 등 지형여건을 종합 고려해 설정".
@@ -12,21 +12,24 @@
* ========================================================================== */
import {
EXTRA_WALL_DEFAULT_HEIGHT_M,
EXTRA_WALL_MIN_HEIGHT_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MIN,
materialLabel,
materialLimit,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type {
OffsetPoint,
OutletFillSegment,
WallAdjust,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
/** 추가 벽 최소 높이(m, 임시) — 이보다 낮아지는 자리(바깥)로는 밀 수 없다. */
export const EXTRA_MIN_HEIGHT_M = 0.5;
export interface OutletExtrasInput {
/** 성토부선 시작점 = 관 유출 하단 꼭짓점(배관 벽의 성토선 시작 자리). */
start: OffsetPoint;
@@ -34,22 +37,17 @@ export interface OutletExtrasInput {
startBottomElevation: number;
outward: number;
groundAt: (offset: number) => number;
/** 표준 높이(관경+여유고) — 자동 자리는 이 높이가 딱 맞는 지점이다. */
wallHeight: number;
/** 형태별 높이 한계(찰 3.0/메 2.0) — 안쪽 당김 한계. */
heightLimit: number;
form: string | null;
/** 계류측 샘플 한계 offset. */
limitOffset: number;
/** 사용자가 민 이동량(m, + = 계류측 바깥) — 요청한 단 수만큼. */
shifts: number[];
/** 단별 사용자 조작값(좌우 x·상하 d·높이 h·재질 m) — 요청한 단 수만큼. */
adjusts: WallAdjust[];
}
export interface OutletExtrasResult {
walls: WallLayout[];
segments: OutletFillSegment[];
/** 한계에 잘린 뒤의 실제 이동량 — 조정창이 되받는다. */
appliedShifts: number[];
/** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다. */
appliedAdjusts: WallAdjust[];
/** 끝 성토부가 아직 5m 이상 — 단을 더 둘 수 있다(의무 구간). */
addable: boolean;
}
@@ -97,23 +95,20 @@ function buildExtraWall(
anchor: OffsetPoint,
outward: number,
height: number,
form: string | null,
groundAt: (offset: number) => number,
material: RevetMaterial,
floatGapM: number,
): WallLayout {
const thickness = REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const frontBase = anchor.offset + outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
let bottomElevation =
Math.min(groundAt(backOffset), groundAt(frontBase), anchor.elevation) - REVET_EMBED_DEPTH_M;
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
bottomElevation = Math.min(bottomElevation, toeGround - REVET_EMBED_DEPTH_M);
// 하단 = 기준선(anchor) 아래 근입 0.5m 고정 — 높이 기준 "근입 0.5 위~상단"과 일치.
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
offset: frontXAt(bottomElevation),
@@ -122,13 +117,14 @@ function buildExtraWall(
return {
role: "extra",
extraIndex: index,
form,
form: materialLabel(material),
lengthM: null,
backOffset,
outerOffset: bottomFront.offset,
base: anchor.elevation,
height,
floatGapM: 0,
floatGapM,
material,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -139,34 +135,43 @@ function buildExtraWall(
}
/**
* 유출측 성토부선·다단 기슭막이 일괄 계산.
* 유출측 성토부선·다단 기슭막이 일괄 계산 (2026-08-22 4축 조작 체계).
*
* 각 단의 벽 높이는 자리에서 역산한다 — 이음선 상단점이 src에서 내려오는
* **1:1.2 사면선 위에 정확히** 놓이는 높이. 닫힌식(D = 벽 중심~src 수평거리):
* 이음선 자리 R = D jointRun(h), 상단 = src.elev R/1.2 = 지반(x) + h
* → h = (src.elev 지반(x) (D 0.25t)/1.2) / (1 0.15/1.2)
* 자동 자리 = h가 표준 높이(관경+여유고)에 처음 닿는 **가장 안쪽 지점**.
* 이동 한계: 안쪽 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단)·높이 한계(찰3/메2),
* 바깥쪽 = 최소 높이 0.5m. 요청 단 수보다 지형이 허락하는 단이 적으면 되는
* 만큼만 세운다 — 조정창이 개수 차이를 보고 토스트로 가능한 단 수를 알린다.
* 단별 벽 높이 h = 사용자 설정 ?? 기본 1.5m (0.5~재질 한계로 절삭). 벽 상단은
* 항상 src에서 내려오는 **1:1.2 성토선 위** — 자동 자리는 높이 h 벽이 지반에
* 앉으며 상단이 선에 닿는 지점(heightAt(x)=h 교차점, 닫힌식):
* heightAt(x) = (src.elev 지반(x) (D 0.25t)/1.2) / (1 0.15/1.2)
* 조작: 좌우 x = src 높이의 **수평 선반**을 끼워 평행이동(물매 불변, 상단 표고
* 불변 — 윗단 기준 0.5m 수평 구현), 상하 d = 성토선을 타는 대각(수평 성분).
* 위(−d) 한계 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단). 바닥은 상단−h−0.5로
* 지반과 무관 — 못 닿으면 뜨고(floatGap 경고), 지나면 묻힌다.
* 요청 단 수보다 지형이 허락하는 단이 적으면 되는 만큼만 세운다.
*/
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
const { outward, groundAt, wallHeight, heightLimit, form, limitOffset } = input;
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 appliedShifts: number[] = [];
const appliedAdjusts: WallAdjust[] = [];
let src = input.start;
let prevBottom = input.startBottomElevation;
for (let i = 0; i < input.shifts.length; i += 1) {
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;
/** 자리 x(벽 하단 중점)에서 1:1.2를 정확히 지키는 벽 높이(닫힌식). */
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 (
@@ -174,66 +179,75 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
heightDenominator
);
};
// 아랫단 상단이 윗단 하단을 넘지 않을 이음선 최소 수평거리(상단 ≤ 윗단 하단).
const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0);
const feasible = (x: number): boolean => {
const h = heightAt(x);
return (
h >= EXTRA_MIN_HEIGHT_M - 1e-9 &&
h <= heightLimit + 1e-9 &&
(x - src.offset) * outward - jointRunOf(h) >= minJointRun - 1e-6
);
};
// 자동 자리 = 표준 높이가 처음 성립하는 가장 안쪽 지점(0.05m 스캔). 표준까지
// 못 크는 지형이면 조건을 만족하는 첫 자리로 폴백.
// 자동 자리 = heightAt이 사용자 높이 h에 처음 닿는 지점(0.05m 스캔 후 선형 보간).
// 지형이 완만해 h까지 못 크면 **가장 깊이 앉는 지점**(heightAt 최대)으로 폴백 —
// 바닥은 상단−h−0.5라 지반과 어긋나도 된다(뜨면 경고, 묻히면 그대로).
const step = 0.05;
const span = (limitOffset - src.offset) * outward;
let auto: number | null = null;
let fallback: number | null = null;
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;
if (!feasible(x)) continue;
if (fallback === null) fallback = x;
if (heightAt(x) >= wallHeight - 1e-9) {
auto = x;
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;
}
}
const autoOffset = auto ?? fallback;
if (autoOffset === null) break; // 이 단은 세울 자리가 없다 — 되는 만큼만.
// 사용자 이동 반영 후, 안 되는 자리면 되는 쪽으로 0.05m씩 되돌린다
// (안쪽 위반 → 바깥으로, 바깥 위반(최소 높이 미달) → 안쪽으로).
let shifted = autoOffset + outward * input.shifts[i];
for (let pass = 0; pass < 400 && !feasible(shifted); pass += 1) {
const h = heightAt(shifted);
const tooInner =
h > heightLimit ||
(shifted - src.offset) * outward - jointRunOf(Math.min(h, heightLimit)) < minJointRun;
shifted += outward * (tooInner ? step : -step);
}
if (!feasible(shifted)) break;
appliedShifts.push(Math.round((shifted - autoOffset) * outward * 10) / 10);
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 appliedD = Math.max(adjust.d, Math.ceil((minJointRun - autoJointRun) * 10) / 10);
const appliedX = Math.max(adjust.x, 0);
appliedAdjusts.push({
x: appliedX,
d: appliedD,
h: adjust.h != null ? height : null,
m: adjust.m,
});
const height = heightAt(shifted);
const anchor: OffsetPoint = { offset: shifted, elevation: groundAt(shifted) };
const wall = buildExtraWall(i, anchor, outward, height, form, groundAt);
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)),
);
walls.push(wall);
// src → 이음선 상단점 — 정확히 1:1.2(높이 역산으로 보장).
const run = Math.abs(wall.topJoint.offset - src.offset);
const rise = src.elevation - wall.topJoint.elevation;
// 성토선: 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: [src, wall.topJoint],
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)의 교차점.
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
// 다음 단 시작점 = 이 벽 하단 +0.5m(기준선) 수평선과 전면 경사선(1:0.3)의 교차점.
src = {
offset:
wall.points[2].offset +
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
elevation: startElevation,
offset: wall.points[2].offset + outward * REVET_LEAN_RATIO * height,
elevation: base,
};
prevBottom = wall.bottomBack.elevation;
}
@@ -245,5 +259,5 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
segments.push(tail);
addable = tail.overLimit;
}
return { walls, segments, appliedShifts, addable };
return { walls, segments, appliedAdjusts, addable };
}
+105 -88
View File
@@ -13,6 +13,11 @@ import {
BASIN_INNER_HEIGHT_M,
BASIN_MAX_FILL_SLOPE_M,
FILL_MIN_RISE_M,
materialFromForm,
materialLabel,
materialLimit,
PIPE_WALL_DEFAULT_HEIGHT_M,
PIPE_WALL_MIN_HEIGHT_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
@@ -23,6 +28,8 @@ import {
revetHeightLimit,
revetTargetHeight,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
BasinShape,
@@ -30,6 +37,7 @@ import type {
EndFace,
OffsetPoint,
PipeEnd,
WallAdjust,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
@@ -47,7 +55,6 @@ import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra";
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
import {
clampToFace,
clampWallOffset,
cutLength,
minShoulderWallOffset,
outletSlopeFactory,
@@ -57,7 +64,6 @@ import {
intersect,
slopeToeOffset,
slopeLengthAlong,
solveWallVertical,
STRAY_LIMIT_M,
} from "./B06_Section_UI_Cross_Culvert_Solve";
@@ -68,8 +74,8 @@ export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U";
export function computeCulvertLayout(
section: CrossSection,
groundSamples: SectionSample[],
/** 사용자가 손으로 민 기슭막이 X 이동량(m, + = 계류측 바깥). 없으면 자동 자리. */
revetShift?: { inlet?: number; outlet?: number; extras?: number[] },
/** 사용자 조작값(좌우 x·상하 d·높이 h·재질 m — 2026-08-22 4축). 없으면 자동. */
revetShift?: { inlet?: WallAdjust; outlet?: WallAdjust; extras?: WallAdjust[] },
/** 유입측 구조물 형식 선택(드롭다운) — 없으면 auto(규칙). */
inletStructure?: InletStructureChoice,
): CulvertLayout | null {
@@ -148,10 +154,24 @@ export function computeCulvertLayout(
inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge));
}
// 성토측 유입 기슭막이는 노견에 붙여 두지 않는다 — 사면선이 벽 이음선 상단점을 지나야
// 하므로 자리를 풀어서 정한다(집수정은 측구부 자리 그대로).
const wallHeightFor = (spec: CulvertSideSpec): number =>
Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
// 벽 제원(2026-08-22 4축 조작): 재질(기본 메쌓기)이 높이 한계(메2/찰3/콘5)를
// 정하고, 높이 = 사용자 설정 ?? 기본 2.5(한계로 절삭). 하한은 2.0과 관경+여유고
// 중 큰 값 — 관이 벽 위로 삐져나오면 안 된다. 자동 자리 계산도 이 높이 기준.
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 inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 조정창 드롭다운 선택지 가용성 — 판정은 분리 파일(Basin)이 맡는다(700줄 제한).
const inletOptions = inletChoiceAvailability({
designAt,
@@ -159,13 +179,16 @@ export function computeCulvertLayout(
edge: inletInfo.edge,
outward: inletInfo.outward,
limitOffset: inletInfo.limit,
wallHeight: wallHeightFor(culvert.inlet),
wallHeight: inletWallSpec.height,
invertCapM: invertCap(inletInfo.edge),
diameterM: diameter,
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
});
/** 요청값이 한계에 걸려 잘린 뒤의 **실제** 이동량. 조정창이 이 값을 되받는다. */
const appliedShift = { inlet: 0, outlet: 0 };
/** 요청값이 한계에 걸려 잘린 뒤의 **실제** 조작값. 조정창이 이 값을 되받는다. */
const appliedAdjust = {
inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.height : null },
outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.height : null },
};
/** 손으로 민 벽의 수직 제원(높이 증가·바닥 띄움) — buildWall에 물려 준다. */
const wallVertical: { inlet: WallVertical | null; outlet: WallVertical | null } = {
inlet: null,
@@ -180,30 +203,30 @@ export function computeCulvertLayout(
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용.
inletAutoOffset = inletOptions.revetAutoOffset;
// 안쪽 당김 한계 = **노견**(도로 밑 침범 금지). 1:1.2 자리 안쪽으로 당기면
// ① 높이 증가 ② 한계 도달 시 바닥 띄움으로 노견·물매를 지킨다
// (2026-08-22 사용자 재확정 — 13+15.7 확인 요청).
const shifted = clampWallOffset(
inletAutoOffset + inletInfo.outward * (revetShift?.inlet ?? 0),
inletInfo.edge.offset_m,
inletInfo.outward,
// 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)),
);
appliedShift.inlet = Math.round((shifted - inletAutoOffset) * inletInfo.outward * 10) / 10;
// 안쪽으로 당겨 노견이 부족하면 ① 높이 증가 ② 바닥 띄움(2026-08-22 사용자 확정).
const vertical = solveWallVertical(
shifted,
inletInfo.edge,
inletInfo.outward,
inletBaseAt(shifted),
wallHeightFor(culvert.inlet),
revetHeightLimit(culvert.inlet.revet_form),
);
// 뜬 바닥(= 관 invert)이 노면−관경−토피 상한을 넘으면 토피가 부족하다 — 상한으로 자른다.
vertical.baseElevation = Math.min(vertical.baseElevation, invertCap(inletInfo.edge));
vertical.floatGapM = Math.max(0, vertical.baseElevation - inletBaseAt(shifted));
wallVertical.inlet = vertical;
inlet.offset = shifted;
inlet.elevation = vertical.baseElevation;
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;
wallVertical.inlet = {
height: h,
baseElevation: invert,
floatGapM: Math.max(0, invert - inletBaseAt(anchorX)),
};
inlet.offset = anchorX;
inlet.elevation = invert;
}
// 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2).
@@ -262,6 +285,7 @@ export function computeCulvertLayout(
forceBasinReason: BasinLayout["reason"] | null,
thickness: number = REVET_THICKNESS_M,
vertical: WallVertical | null = null,
material: RevetMaterial = "dry",
): WallLayout | null => {
const reason: BasinLayout["reason"] | null =
// 백엔드 spec의 "집수정"은 판정(ruleReason)에 이미 반영됐다 — 사용자 선택
@@ -293,12 +317,8 @@ export function computeCulvertLayout(
return null;
}
// 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면
// (계류측)은 1:0.3 기운 평행사변형 띠. 띠 안쪽 평행선과 수직 배면 사이가 사다리꼴로,
// 바닥이 넓고 상단이 좁다(상단 폭 = 띠 두께 0.45).
// 높이: 기본은 **관경 + 여유고**를 0.1m 눈금으로 올린 값(2026-08-21 사용자 확정).
// 손으로 안쪽에 당겨 노견이 부족하면 수직 제원(vertical)이 높이를 키우거나 바닥을
// 띄워 들어온다(2026-08-22 사용자 확정 — 최소 노견 유지).
// 형태별 법정·교본 높이 한계(찰 3.0 / 메 2.0 — 돌쌓기.md §1)를 넘지 못한다.
// (계류측)은 1:0.3 기운 평행사변형 띠. 높이는 4축 조작 제원(vertical)이 들고
// 들어온다 — 계산용 높이(근입 0.5 위 기준선~상단), 재질 한계는 pipeWallSpec 절삭.
const height =
vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
@@ -308,30 +328,18 @@ export function computeCulvertLayout(
// 그 자리 원지반(= 관 invert)이다.
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const frontBase = anchor.offset + outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
// 상단 변: 사다리꼴 상단(t/2) + 띠(t). 이음선은 상단 변 중간점에서 1:0.3으로 바닥까지.
const topFront = topJoint + outward * thickness;
// 하단선: **수평**(2026-08-22 사용자 확정 — 기초공사는 수평 기초라 지반을 따라
// 기울지 않는다). 깊이는 벽 발자국 안 기준선(원지반 또는 관 invert)의 **최저점**
// 아래 0.5m — 기슭막이는 관을 감싸는 구조물이라 관 하단(invert) 아래 0.5m는 어떤
// 경우에도 확보한다. 바닥이 원지반에서 뜬 벽(floatGapM>0)은 관 invert 기준 0.5만
// 내려간다(뜬 공간은 별도 지지 구조물 — 추가 예정).
// 전면(계류측) 경사선(1:0.3)은 그대로 하단까지 **연장**한다 — 바닥이 깊어진 만큼
// 전면 발끝이 바깥으로 나간다. 발끝 지반이 더 낮으면 한 번 더 내린다(2-pass).
// 하단선: **수평 기초**, 깊이 = 기준선(관 invert) 아래 근입 0.5m 고정 —
// 높이 기준을 "근입 0.5 위 기준선~상단"으로 확정하며 발자국 최저 지반 추적을
// 대체했다(2026-08-22 사용자). 지반이 더 낮으면 바닥이 뜨고(floatGapM 경고),
// 더 높으면 그만큼 더 묻힌다. 전면 경사선(1:0.3)은 하단까지 연장.
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
let bottomElevation =
(floatGapM > 1e-6
? anchor.elevation
: Math.min(groundAt(backOffset), groundAt(frontBase), anchor.elevation)) -
REVET_EMBED_DEPTH_M;
if (floatGapM <= 1e-6) {
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
bottomElevation = Math.min(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),
@@ -339,7 +347,7 @@ export function computeCulvertLayout(
};
const wall: WallLayout = {
role: spec.role,
form: spec.revet_form ?? null,
form: materialLabel(material),
lengthM: spec.revet_length_m ?? null,
backOffset,
outerOffset: bottomFront.offset,
@@ -348,6 +356,7 @@ export function computeCulvertLayout(
// 확정 — 기초는 시공 시 묻히는 부분이라 높이·한계 검사 모두 이 기준).
height,
floatGapM,
material,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -381,6 +390,7 @@ export function computeCulvertLayout(
basinReason,
REVET_THICKNESS_M,
wallVertical.inlet,
inletWallSpec.material,
);
// 유출 벽 자리 — 유입과 같은 규칙(사면선이 벽 이음선 상단점을 지나는 자리, 물매는
// 1:1.2~2.0 범위에서 역산).
@@ -396,7 +406,7 @@ export function computeCulvertLayout(
? minShoulderWallOffset(
outletInfo.edge,
outletInfo.outward,
wallHeightFor(culvert.outlet),
outletWallSpec.height,
invertAt,
outletAnchorOffset,
outletInfo.limit,
@@ -404,26 +414,26 @@ export function computeCulvertLayout(
: null;
const outletAutoOffset = outletFeasible ?? outletAnchorOffset;
if (designAt) {
// 안쪽 당김 한계 = 노견(유입과 동일 — 안쪽은 높이 증가·바닥 띄움으로 잇는다).
const shifted = clampWallOffset(
outletAutoOffset + outletInfo.outward * (revetShift?.outlet ?? 0),
outletInfo.edge.offset_m,
outletInfo.outward,
// 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)),
);
appliedShift.outlet = Math.round((shifted - outletAutoOffset) * outletInfo.outward * 10) / 10;
const vertical = solveWallVertical(
shifted,
outletInfo.edge,
outletInfo.outward,
invertAt(shifted),
wallHeightFor(culvert.outlet),
revetHeightLimit(culvert.outlet.revet_form),
);
// 뜬 바닥(= 유출 invert)이 유입 invert보다 높으면 역경사다 — 유입 높이로 자른다.
vertical.baseElevation = Math.min(vertical.baseElevation, inlet.elevation);
vertical.floatGapM = Math.max(0, vertical.baseElevation - invertAt(shifted));
wallVertical.outlet = vertical;
outletWallAnchor = { offset: shifted, elevation: vertical.baseElevation };
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;
wallVertical.outlet = {
height: h,
baseElevation: invert,
floatGapM: Math.max(0, invert - invertAt(anchorX)),
};
outletWallAnchor = { offset: anchorX, elevation: invert };
}
let outletWall = buildWall(
culvert.outlet,
@@ -432,6 +442,7 @@ export function computeCulvertLayout(
null,
REVET_THICKNESS_M,
wallVertical.outlet,
outletWallSpec.material,
);
// ── 관 축 확정. 관 하단선은 **관 시작점(집수정 내공 벽 또는 유입 벽 자리)과
// 유출 벽 하단선 중점(invert)을 잇는 직선**이다 — 벽이 어디로 가든 관은 그 벽
@@ -502,13 +513,13 @@ export function computeCulvertLayout(
// 길이가 다르다** — 긴 변을 기준으로 올림해 정수 m로 잡고, 모자란 만큼 **유출 벽 자리**
// 를 관 축 방향 바깥으로 민다(벽 두께는 0.45 고정 — 사용자 확정). 벽 상단이 사면선과
// 벌어지는 문제는 설계선 트림이 벽 상단을 따라오므로 생기지 않는다(사용자 ②).
const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, wallHeightFor(culvert.outlet));
const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, outletWallSpec.height);
/** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */
const outletThickness = REVET_THICKNESS_M;
// 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22 사용자 확정 — 종전에는
// 한쪽 벽을 밀면 이 루프가 반대쪽 벽을 따라 옮겨 좌우가 동시에 움직였다). 관 그림은
// 두 벽 전면 사이 실길이로 두고 표기 길이만 올림(m)한다.
const outletPinned = Math.abs(revetShift?.outlet ?? 0) > 1e-9;
const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || Math.abs(adjOutlet.d) > 1e-9;
if (outletWall && !outletPinned) {
const target = Math.ceil(cutLength(pipeCorners) - 1e-6);
const rebuild = (): void => {
@@ -519,6 +530,12 @@ export function computeCulvertLayout(
outletInfo.outward,
null,
outletThickness,
{
height: outletWallSpec.height,
baseElevation: outletWallAnchor.elevation,
floatGapM: Math.max(0, outletWallAnchor.elevation - invertAt(outletWallAnchor.offset)),
},
outletWallSpec.material,
);
// 관 하단선은 옮겨진 벽의 **하단선 중점을 지나야** 한다 — 옛 축을 그대로 늘리면
// 가파른 지반에서 관이 벽 바닥 아래로 삐져나간다(2026-08-22 13+15.7 사용자 지적).
@@ -542,8 +559,7 @@ export function computeCulvertLayout(
// 사면 역전·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저다.
if (
(moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 ||
outletInfo.edge.elevation_m - (invertAt(moved) + wallHeightFor(culvert.outlet)) <
FILL_MIN_RISE_M
outletInfo.edge.elevation_m - (invertAt(moved) + outletWallSpec.height) < FILL_MIN_RISE_M
) {
break;
}
@@ -590,13 +606,10 @@ export function computeCulvertLayout(
startBottomElevation: outletWall.bottomBack.elevation,
outward: outletInfo.outward,
groundAt,
wallHeight: wallHeightFor(culvert.outlet),
heightLimit: revetHeightLimit(culvert.outlet.revet_form),
form: culvert.outlet.revet_form ?? null,
limitOffset: outletInfo.limit,
shifts: revetShift?.extras ?? [],
adjusts: (revetShift?.extras ?? []).map(adjustOf),
})
: { walls: [], segments: [], appliedShifts: [], addable: false };
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
@@ -643,7 +656,11 @@ export function computeCulvertLayout(
outletFill: { segments: extras.segments, addable: extras.addable },
extraWalls: extras.walls,
basin,
revetShift: { ...appliedShift, extras: extras.appliedShifts },
revetShift: {
inlet: appliedAdjust.inlet,
outlet: appliedAdjust.outlet,
extras: extras.appliedAdjusts,
},
designTrim:
walls.length || basin
? {
@@ -5,6 +5,26 @@
* ========================================================================== */
import type { CulvertSet } from "./B06_Section_Api_Fetch";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
/**
* 기슭막이 1매의 사용자 조작값(2026-08-22 확정 4축):
* x = 좌우(m, + = 계류측 바깥) — 노견(또는 윗단 시작점 수평선)을 늘려 평행이동,
* 성토선 물매 1:1.2 불변.
* d = 상하(m, 수평 성분, + = 사면 아래) — 성토선을 타고 대각 이동.
* h = 높이(m, 계산용: 근입 0.5 위 기준선~상단). null = 기본값
* (배관 2.5·일반 1.5, 재질 한계로 잘림).
* m = 재질. null = 기본값(메쌓기). 한계: 메 2.0 / 찰 3.0 / 콘크리트 5.0.
*/
export interface WallAdjust {
x: number;
d: number;
h: number | null;
m: RevetMaterial | null;
}
/** 조작값 기본(전부 자동). */
export const ZERO_ADJUST: WallAdjust = { x: 0, d: 0, h: null, m: null };
export interface OffsetPoint {
offset: number;
@@ -46,6 +66,8 @@ export interface WallLayout {
* 별도 지지 구조물로 메운다(추가 예정). 0이면 지반 위(근입 0.5m).
*/
floatGapM: number;
/** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */
material: RevetMaterial;
outward: number;
/** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */
points: OffsetPoint[];
@@ -145,7 +167,7 @@ export interface CulvertLayout {
* 한계에 걸리면 잘려 들어온다 — 조정창은 이 값을 보여 주고 저장한다. 그러지 않으면
* 눌러도 안 움직이는데 숫자만 계속 커진다(2026-08-21 사용자 지적).
*/
revetShift: { inlet: number; outlet: number; extras: number[] };
revetShift: { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[] };
/**
* 조정창 드롭다운 선택지 가용성(2026-08-22 사용자 — 상황에 안 맞는 선택지 숨김):
* revetAllowed = 기슭막이+배관(배관이 원지반에 안 묻힐 때만),
@@ -6,12 +6,40 @@
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert";
import type { CulvertLayout, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import type {
ExtraWallControl,
InletStructureControl,
RevetOffsetControl,
} from "./B06_Section_UI_Cross_View";
import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import { materialLabel, materialLimit } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
/**
* 기슭막이 4축 조작 제어(2026-08-22 확정) — 벽을 눌러 고르고 조정창에서
* 좌우(x)·상하(d, 성토선 대각)·높이(h)·재질(m)을 만진다. 고른 벽은 여기 담아 두어
* 카드가 다시 그려져도 되살아난다. `select`는 값만 담고 다시 그리지 않는다.
*/
export interface RevetOffsetControl {
adjustFor: (section: CrossSection, role: RevetKey) => WallAdjust;
selectedFor: (section: CrossSection) => RevetKey | null;
select: (chainageM: number, key: RevetKey | null) => void;
/** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */
syncApplied: (chainageM: number, role: RevetKey, applied: WallAdjust) => void;
/** 일부 축만 바꾼다 — 나머지는 유지. h: null = 기본값 복귀. */
update: (chainageM: number, role: RevetKey, patch: Partial<WallAdjust>) => void;
reset: (chainageM: number, role: RevetKey) => void;
}
/** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22 사용자). 세션 보관은 Page가 한다. */
export interface InletStructureControl {
valueFor: (section: CrossSection) => InletStructureChoice;
set: (chainageM: number, value: InletStructureChoice) => void;
}
/** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */
export interface ExtraWallControl {
countFor: (section: CrossSection) => number;
/** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */
setCount: (chainageM: number, count: number) => void;
/** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */
syncCount: (chainageM: number, built: number) => void;
}
import { showToast } from "@ui/ui_template_elements";
import { L } from "./B06_Section_UI_Section_Common";
@@ -39,10 +67,10 @@ export function computeCardCulvert(
sourceSamples,
revetOffset
? {
inlet: revetOffset.shiftFor(section, "inlet"),
outlet: revetOffset.shiftFor(section, "outlet"),
inlet: revetOffset.adjustFor(section, "inlet"),
outlet: revetOffset.adjustFor(section, "outlet"),
extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) =>
revetOffset.shiftFor(section, `extra${i}`),
revetOffset.adjustFor(section, `extra${i}`),
),
}
: undefined,
@@ -61,21 +89,54 @@ export function computeCardCulvert(
extraWalls.syncCount(section.chainage_m, layout.extraWalls.length);
}
if (revetOffset) {
const roles: Array<[RevetKey, number]> = [
const roles: Array<[RevetKey, WallAdjust]> = [
["inlet", layout.revetShift.inlet],
["outlet", layout.revetShift.outlet],
...layout.revetShift.extras.map((applied, i): [RevetKey, number] => [`extra${i}`, applied]),
...layout.revetShift.extras.map((applied, i): [RevetKey, WallAdjust] => [
`extra${i}`,
applied,
]),
];
const wallOf = (role: RevetKey) =>
role.startsWith("extra")
? layout.extraWalls[Number(role.slice(5))]
: layout.walls.find((wall) => wall.role === role);
for (const [role, applied] of roles) {
const requested = revetOffset.shiftFor(section, role);
if (activeRevet === role && Math.abs(requested - applied) > 0.05) {
// 이동량 부호: + = 계류측 바깥. 요청이 적용보다 작으면 안쪽 요청이 잘린 것.
showToast(
L(requested < applied ? "B06_Cross_Revet_Limit_Inward" : "B06_Cross_Revet_Limit_Outward"),
"info",
);
const requested = revetOffset.adjustFor(section, role);
// 축별로 요청이 잘렸으면 왜 안 됐는지 토스트로 알린다(조작 중인 벽만).
if (activeRevet === role) {
if (Math.abs(requested.x - applied.x) > 0.05) {
showToast(
L(
requested.x < applied.x
? "B06_Cross_Revet_Limit_Inward"
: "B06_Cross_Revet_Limit_Outward",
),
"info",
);
}
if (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",
);
}
// 높이가 재질 한계에 잘렸으면 재질 변경을 안내(2026-08-22 사용자 — 경고와
// 높이 해제는 재질 선택으로).
const wall = wallOf(role);
if (requested.h != null && applied.h != null && requested.h > applied.h + 0.05 && wall) {
showToast(
L("B06_Cross_Height_Limit")
.replace("{mat}", materialLabel(wall.material))
.replace("{limit}", materialLimit(wall.material).toFixed(1)),
"info",
);
}
if (requested.h != null && applied.h != null && requested.h < applied.h - 0.05) {
showToast(L("B06_Cross_Height_Floor"), "info");
}
}
revetOffset.syncShift(section.chainage_m, role, applied);
revetOffset.syncApplied(section.chainage_m, role, applied);
}
}
return layout;
@@ -1,25 +1,36 @@
/* =============================================================================
* B06_Section_UI_Cross_Structure_Panel.ts
* 횡단도 안에 뜨는 **구조물 위치 조정 오버레이 창**(2026-08-21 사용자 확정).
* 횡단도 안에 뜨는 **구조물 조정 오버레이 창**(2026-08-21 사용자 확정).
*
* 조정 조작구는 카드 하단이 아니라 **해당 횡단도 안에** 있어야 한다 — 어느 도면의
* 구조물을 만지는지 눈으로 붙어 있어야 하고, 카드 하단은 표시 반폭 조작구와 섞여
* 구분이 안 됐다. 앞으로 구조물 위치 조정은 모두 이 창을 통한다.
* 구조물을 만지는지 눈으로 붙어 있어야 한다. 기슭막이는 4축(2026-08-22 확정):
* 좌우 ◀▶(노견/시작점 수평 연장, 물매 1:1.2 불변), 상하 ▲▼(성토선을 타는 대각),
* 높이 ±0.1m(재질 한계), 재질(메2.0/찰3.0/콘크리트5.0). 다단은 단 수 숫자++/-.
*
* 창은 그래프 영역(`.b06-cross-card__chart-wrap`) 안에 절대 위치로 얹힌다. 줌 버튼은
* 우측 상단, 면적표는 중상단이라 이 창은 **좌측 하단**에 둔다.
* 창은 그래프 영역(`.b06-cross-card__chart-wrap`) 안 **좌측 하단**에 얹힌다.
* ========================================================================== */
import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import { materialLimit, REVET_MATERIALS } 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 { L } from "./B06_Section_UI_Section_Common";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
export interface StructurePanelDeps {
/** 지금 이동량(m, + = 계류측 바깥). 창을 열거나 값이 바뀔 때마다 읽는다. */
shiftFor: (key: RevetKey) => number;
/** 화면 좌(+)/우() 방향으로 미는 양(m). 부호 환산은 카드가 한다. */
/** 지금 조작값(좌우 x·상하 d·높이 h·재질 m). 창을 열거나 값이 바뀔 때마다 읽는다. */
adjustFor: (key: RevetKey) => WallAdjust;
/** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */
nudge: (key: RevetKey, screenDeltaM: number) => void;
/** 자동 자리로 되돌린다. */
/** 상하: 성토선을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
nudgeSlope: (key: RevetKey, deltaM: number) => void;
/** 높이 ±0.1m. 재질 한계 절삭·경고는 기하·카드가 한다. */
nudgeHeight: (key: RevetKey, deltaM: number) => void;
/** 실제 그려진 벽 높이(m) — 표시·높이 조작의 기준값. */
heightFor: (key: RevetKey) => number;
materialFor: (key: RevetKey) => RevetMaterial;
setMaterial: (key: RevetKey, material: RevetMaterial) => void;
/** 자동 자리로 되돌린다(4축 모두). */
reset: (key: RevetKey) => void;
/** 지금 관 길이(m) — 조정 단위가 관 길이 1m이라 창에 같이 적는다. */
pipeLengthM: () => number | null;
@@ -29,14 +40,13 @@ export interface StructurePanelDeps {
structureFor: () => InletStructureChoice;
/** 유입측 구조물 형식 변경 — 카드를 다시 그린다. */
setStructure: (value: InletStructureChoice) => void;
/** ◀/▶ 이동 가능 여부 — 집수정(자리 고정)은 숨긴다. */
/** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */
canNudge: (key: RevetKey) => boolean;
/** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */
optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean };
/** 다단 기슭막이 상태 — 유출 벽 선택 시 개수 입력 행 표시 판단에 쓴다. */
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
extraState: () => { canAdd: boolean; count: number };
/** 다단 기슭막이 단 수 지정(2026-08-22 사용자 — 숫자 입력). 지형락하는
* 단 수보다 크면 기하가 되는 만큼만 세우고 토스트로 알린다. */
/** 다단 기슭막이 단 수 지정 지형 허용보다 크면 기하가 자르고 토스트로 알린다. */
setExtraCount: (count: number) => void;
}
@@ -47,11 +57,11 @@ export interface StructurePanelHandle {
}
/**
* 한 걸음 이동량(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정).
* 관은 m 단위로 설치하므로 0.1m씩 밀면 그 끝수를 벽 폭이 흡수하게 되어 미는 동안
* 벽 단면이 변한다. 한 걸음을 1m로 두면 벽은 자리만 옮기고 형상은 그대로다.
* 좌우·상하 한 걸음(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정).
* 높이는 0.1m 눈금(2026-08-22 사용자 확정 2~3m·0.5~3m 구간 0.1 단위 제어).
*/
const STEP_M = 1.0;
const HEIGHT_STEP_M = 0.1;
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
@@ -67,10 +77,11 @@ function makeButton(label: string, title: string, onClick: () => void): HTMLButt
return button;
}
/** 구조물 위치 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle {
const root = document.createElement("div");
root.className = "b06-structure-panel is-hidden";
root.className = "b06-structure-panel";
root.classList.add("is-hidden");
// 창 안에서의 클릭이 카드 선택으로 번지지 않게 한 번에 막는다.
root.addEventListener("click", (event) => event.stopPropagation());
@@ -98,6 +109,16 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
L("B06_Cross_Revet_Right"),
act((key) => deps.nudge(key, -STEP_M)),
),
makeButton(
"▲",
L("B06_Cross_Revet_Up"),
act((key) => deps.nudgeSlope(key, -STEP_M)),
),
makeButton(
"▼",
L("B06_Cross_Revet_Down"),
act((key) => deps.nudgeSlope(key, STEP_M)),
),
makeButton(
"↺",
L("B06_Cross_Revet_Reset"),
@@ -105,23 +126,40 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
),
);
// 다단 기슭막이 단 수 입력(2026-08-22 사용자) — 유출 벽 기준 숫자 입력.
const extraRow = document.createElement("label");
extraRow.className = "b06-structure-panel__struct";
const extraLabel = document.createElement("span");
extraLabel.textContent = L("B06_Cross_Extra_Count");
const countInput = document.createElement("input");
countInput.type = "number";
countInput.min = "0";
countInput.max = "9";
countInput.step = "1";
countInput.className = "b06-structure-panel__count";
countInput.addEventListener("change", () => {
const requested = Math.max(0, Math.min(9, Math.round(Number(countInput.value) || 0)));
countInput.value = String(requested);
deps.setExtraCount(requested);
// 높이·재질 행(2026-08-22 사용자) — 높이 ±0.1m, 재질이 한계를 정한다.
const heightRow = document.createElement("div");
heightRow.className = "b06-structure-panel__struct";
const heightLabel = document.createElement("span");
heightLabel.textContent = L("B06_Cross_Height_Label");
const heightValue = document.createElement("span");
heightValue.className = "b06-structure-panel__hval";
const heightMinus = makeButton(
"",
L("B06_Cross_Height_Minus"),
act((key) => deps.nudgeHeight(key, -HEIGHT_STEP_M)),
);
const heightPlus = makeButton(
"",
L("B06_Cross_Height_Plus"),
act((key) => deps.nudgeHeight(key, HEIGHT_STEP_M)),
);
const materialSelect = document.createElement("select");
materialSelect.className = "b06-structure-panel__select";
const MATERIAL_LABEL: Record<RevetMaterial, string> = {
dry: L("B06_Cross_Mat_Dry"),
wet: L("B06_Cross_Mat_Wet"),
concrete: L("B06_Cross_Mat_Concrete"),
};
for (const material of REVET_MATERIALS) {
const option = document.createElement("option");
option.value = material;
option.textContent = `${MATERIAL_LABEL[material]}(~${materialLimit(material).toFixed(1)}m)`;
materialSelect.append(option);
}
materialSelect.addEventListener("change", () => {
if (current) deps.setMaterial(current, materialSelect.value as RevetMaterial);
});
extraRow.append(extraLabel, countInput);
heightRow.append(heightLabel, heightMinus, heightValue, heightPlus, materialSelect);
// 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ.
const structureRow = document.createElement("label");
@@ -148,13 +186,39 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
});
structureRow.append(structureLabel, select);
// 다단 기슭막이 단 수(2026-08-22 사용자) — 숫자 입력 + +/- 병행(스피너는 CSS 제거).
const extraRow = document.createElement("label");
extraRow.className = "b06-structure-panel__struct";
const extraLabel = document.createElement("span");
extraLabel.textContent = L("B06_Cross_Extra_Count");
const countInput = document.createElement("input");
countInput.type = "number";
countInput.min = "0";
countInput.max = "9";
countInput.step = "1";
countInput.className = "b06-structure-panel__count";
const clampCount = (value: number): number =>
Math.max(0, Math.min(9, Math.round(Number.isFinite(value) ? value : 0)));
countInput.addEventListener("change", () => {
const requested = clampCount(Number(countInput.value));
countInput.value = String(requested);
deps.setExtraCount(requested);
});
const countMinus = makeButton("", L("B06_Cross_Extra_Remove"), () =>
deps.setExtraCount(clampCount(Number(countInput.value) - 1)),
);
const countPlus = makeButton("", L("B06_Cross_Extra_Add"), () =>
deps.setExtraCount(clampCount(Number(countInput.value) + 1)),
);
extraRow.append(extraLabel, countMinus, countInput, countPlus);
const closeButton = makeButton("✕", L("B06_Cross_Revet_Close"), () => deps.close());
closeButton.classList.add("b06-structure-panel__close");
const head = document.createElement("div");
head.className = "b06-structure-panel__head";
head.append(title, closeButton);
root.append(head, value, structureRow, extraRow, buttons);
root.append(head, value, structureRow, heightRow, extraRow, buttons);
return {
root,
@@ -163,45 +227,57 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
root.classList.toggle("is-hidden", key === null);
if (!key) return;
const isExtra = key.startsWith("extra");
const movable = deps.canNudge(key);
title.textContent = isExtra
? `${L("B06_Cross_Revet_Extra")} ${Number(key.slice(5)) + 1}`
: key === "outlet"
? L("B06_Cross_Revet_Outlet")
: deps.canNudge("inlet")
: movable
? L("B06_Cross_Revet_Inlet")
: L("B06_Cross_Struct_InletBasin");
// 형식 선택은 유입측에서만, ◀/▶/↺는 자리를 옮길 수 있는 구조물(기슭막이)만.
// 형식 선택은 유입측에서만, 이동·높이 조작은 기슭막이(집수정 제외)만.
structureRow.classList.toggle("is-hidden", key !== "inlet");
if (key === "inlet") {
const current = deps.structureFor();
const structure = deps.structureFor();
// 상황에 안 맞는 선택지는 숨긴다(2026-08-22 사용자). 단 지금 선택된 값은
// 남긴다 — 숨기면 셀렉트가 빈 값이 된다.
const allow = deps.optionsFor();
for (const option of select.options) {
const value = option.value as InletStructureChoice;
const optionValue = option.value as InletStructureChoice;
const hidden =
(value === "revet" && !allow.revetAllowed) ||
((value === "L" || value === "U") && !allow.basinLUAllowed);
option.hidden = hidden && value !== current;
(optionValue === "revet" && !allow.revetAllowed) ||
((optionValue === "L" || optionValue === "U") && !allow.basinLUAllowed);
option.hidden = hidden && optionValue !== structure;
}
select.value = current;
select.value = structure;
}
buttons.classList.toggle("is-hidden", !deps.canNudge(key));
// 단 수 입력은 **유출 벽에서만** — 성토부가 5m 이상(의무)이거나 이미 단이 있을 때.
buttons.classList.toggle("is-hidden", !movable);
heightRow.classList.toggle("is-hidden", !movable);
if (movable) {
heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`;
materialSelect.value = deps.materialFor(key);
}
// 단 수 행은 **유출 벽에서만** — 성토부 5m 이상(의무)이거나 이미 단이 있을 때.
const extra = deps.extraState();
extraRow.classList.toggle(
"is-hidden",
!(key === "outlet" && (extra.canAdd || extra.count > 0)),
);
countInput.value = String(extra.count);
const shift = deps.shiftFor(key);
// 자동 자리 기준 이동량 — 0이면 "자동"으로 적어 손을 안 댔음을 바로 알린다.
// 부호(+/−)만 적으면 좌·우 벽에서 어느 쪽인지 읽히지 않아 **안/바깥**으로 적는다.
const direction = L(shift > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward");
const moved =
Math.abs(shift) < 1e-9
? L("B06_Cross_Revet_Auto")
: `${direction} ${Math.abs(shift).toFixed(1)}m`;
const adjust = deps.adjustFor(key);
// 조작 요약 — 0이면 "자동". 좌우는 안/바깥, 상하는 위/아래로 적는다.
const parts: string[] = [];
if (Math.abs(adjust.x) > 1e-9) {
parts.push(
`${L(adjust.x > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward")} ${Math.abs(adjust.x).toFixed(1)}m`,
);
}
if (Math.abs(adjust.d) > 1e-9) {
parts.push(
`${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m`,
);
}
const moved = parts.length ? parts.join(" · ") : L("B06_Cross_Revet_Auto");
const pipeLength = deps.pipeLengthM();
value.textContent =
pipeLength === null ? moved : `${L("B06_Cross_Revet_Pipe")} ${pipeLength}m · ${moved}`;
+44 -32
View File
@@ -25,10 +25,16 @@ import {
} from "./B06_Section_UI_Cross_Design";
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert";
import type {
ExtraWallControl,
InletStructureControl,
RevetOffsetControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import {
CROSS_HEIGHT,
CROSS_PAD,
@@ -160,35 +166,13 @@ export interface StationWidthControl {
reset: (chainageM: number) => void;
}
/**
* 기슭막이 X 자리 제어(2026-08-21 사용자 ①) — 벽을 눌러 고르고 ◀/▶로 0.1m씩 민다.
* 고른 벽은 여기 담아 두어 카드가 다시 그려져도 되살아난다. `select`는 값만 담고
* 다시 그리지 않는다 — 강조는 클래스만 갈아 끼워 줌·팬을 지킨다.
*/
export interface RevetOffsetControl {
shiftFor: (section: CrossSection, role: RevetKey) => number;
selectedFor: (section: CrossSection) => RevetKey | null;
select: (chainageM: number, key: RevetKey | null) => void;
/** 기하가 실제로 적용한 이동량을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */
syncShift: (chainageM: number, role: RevetKey, appliedM: number) => void;
adjust: (chainageM: number, role: RevetKey, deltaM: number) => void;
reset: (chainageM: number, role: RevetKey) => void;
}
/** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22 사용자). 세션 보관은 Page가 한다. */
export interface InletStructureControl {
valueFor: (section: CrossSection) => InletStructureChoice;
set: (chainageM: number, value: InletStructureChoice) => void;
}
/** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */
export interface ExtraWallControl {
countFor: (section: CrossSection) => number;
/** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */
setCount: (chainageM: number, count: number) => void;
/** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */
syncCount: (chainageM: number, built: number) => void;
}
// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire로 옮겼다(700줄 제한) —
// 기존 import 경로 유지를 위해 재수출한다.
export type {
ExtraWallControl,
InletStructureControl,
RevetOffsetControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
export function createCrossSectionCard(
section: CrossSection,
@@ -243,6 +227,8 @@ export function createCrossSectionCard(
let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true };
/** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */
let culvertExtraState = { canAdd: false, count: 0 };
/** 마지막 계산의 벽 제원(높이·재질) — 조정창 높이 행 표시·높이 조작 기준값. */
let culvertWallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>();
/**
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
* 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도
@@ -519,6 +505,12 @@ export function createCrossSectionCard(
canAdd: culvertLayout.outletFill.addable,
count: culvertLayout.extraWalls.length,
};
culvertWallSpecs = new Map();
for (const wall of [...culvertLayout.walls, ...culvertLayout.extraWalls]) {
const key =
wall.role === "extra" ? (`extra${wall.extraIndex ?? 0}` as RevetKey) : wall.role;
culvertWallSpecs.set(key, { height: wall.height, material: wall.material });
}
}
if (culvertLayout) {
setRevetActive = appendCulvertOverlay(
@@ -605,10 +597,30 @@ export function createCrossSectionCard(
// 화면 좌(◀)로 민다 = offset이 커진다 — 벽 기준 이동량으로 환산해 넘긴다.
const outwardOf = (role: RevetKey): number =>
((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1;
const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0;
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 };
const panel = buildStructurePanel({
shiftFor: (key) => revetOffset?.shiftFor(section, key) ?? 0,
adjustFor: adjustOf,
nudge: (key, screenDeltaM) =>
revetOffset?.adjust(section.chainage_m, key, screenDeltaM * outwardOf(key)),
revetOffset?.update(section.chainage_m, key, {
x: adjustOf(key).x + screenDeltaM * outwardOf(key),
}),
// 상하(대각) — ▲(위) = 성토선을 타고 노견 쪽, ▼(아래) = 계류 쪽. 수평 성분 1m.
nudgeSlope: (key, deltaM) =>
revetOffset?.update(section.chainage_m, key, { d: adjustOf(key).d + deltaM }),
nudgeHeight: (key, deltaM) => {
const current = adjustOf(key);
const base = current.h ?? heightOfWall(key);
revetOffset?.update(section.chainage_m, key, {
h: Math.round((base + deltaM) * 10) / 10,
});
},
materialFor: (key) => materialOfWall(key),
setMaterial: (key, material) => revetOffset?.update(section.chainage_m, key, { m: material }),
heightFor: (key) => heightOfWall(key),
reset: (key) => revetOffset?.reset(section.chainage_m, key),
pipeLengthM: () => culvertPipeLengthM,
close: () => {
@@ -7,6 +7,8 @@
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
ExtraWallControl,
InletStructureControl,
@@ -106,11 +108,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
};
/* ── 기슭막이 X 자리(2026-08-21 사용자 ①) ────────────────────────────
* 벽을 눌러 고르고 카드 하단 ◀/▶로 0.1m씩 민다. 값은 세션에만 담는다 — 자동 자리가
* 지형·계획고를 따라 다시 풀리므로, 손으로 민 값은 그 세션의 표시 조정으로 본다.
* 키는 `누가거리:역할`. `select`는 다시 그리지 않고 값만 담는다(줌·팬 보존). */
const revetShifts = new Map<string, number>();
/* ── 기슭막이 4축 조작값(2026-08-22 확정: 좌우 x·상하 d·높이 h·재질 m) ──
* 값은 세션에만 담는다 — 자동 자리가 지형·계획고를 따라 다시 풀리므로, 손으로
* 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`.
* 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */
const revetShifts = new Map<string, WallAdjust>();
const revetSelected = new Map<string, RevetKey>();
const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`;
const revetSessionKey = (): string | null => deps.sessionKey("revetx");
@@ -123,9 +125,13 @@ export function createStationControls(deps: StationControlDeps): StationControls
try {
const raw = window.sessionStorage.getItem(key);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([entry, shift]) => {
if (Number.isFinite(shift)) revetShifts.set(entry, shift);
const parsed = JSON.parse(raw) as Record<string, number | Partial<WallAdjust>>;
Object.entries(parsed).forEach(([entry, value]) => {
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 });
}
});
} catch {
/* 손상된 세션 값은 무시 — 자동 자리로 재시작. */
@@ -143,28 +149,47 @@ export function createStationControls(deps: StationControlDeps): StationControls
}
/** 손으로 미는 범위 한계(m) — 조정 단위가 관 길이 1m이라 ±10m(=관 10m분)까지 둔다. */
const clampRevetShift = (value: number): number =>
Math.min(Math.max(Math.round(value * 10) / 10, -10), 10);
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;
const storeAdjust = (key: string, value: WallAdjust): void => {
if (isDefaultAdjust(value)) revetShifts.delete(key);
else revetShifts.set(key, value);
persistRevetShifts();
};
const sameAdjust = (a: WallAdjust, b: WallAdjust): boolean =>
Math.abs(a.x - b.x) < 1e-9 &&
Math.abs(a.d - b.d) < 1e-9 &&
(a.h ?? null) === (b.h ?? null) &&
(a.m ?? null) === (b.m ?? null);
const revetOffsetControl: RevetOffsetControl = {
shiftFor: (section, role) => revetShifts.get(revetKey(section.chainage_m, role)) ?? 0,
adjustFor: (section, role) =>
revetShifts.get(revetKey(section.chainage_m, role)) ?? { ...ZERO_ADJUST },
selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null,
select: (chainageM, key) => {
if (key) revetSelected.set(chainageM.toFixed(2), key);
else revetSelected.delete(chainageM.toFixed(2));
},
syncShift: (chainageM, role, appliedM) => {
syncApplied: (chainageM, role, applied) => {
// 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지.
const key = revetKey(chainageM, role);
const stored = revetShifts.get(key) ?? 0;
if (Math.abs(stored - appliedM) < 1e-9) return;
if (Math.abs(appliedM) < 1e-9) revetShifts.delete(key);
else revetShifts.set(key, appliedM);
persistRevetShifts();
const stored = revetShifts.get(key) ?? { ...ZERO_ADJUST };
if (sameAdjust(stored, applied)) return;
storeAdjust(key, { ...applied });
},
adjust: (chainageM, role, deltaM) => {
update: (chainageM, role, patch) => {
const key = revetKey(chainageM, role);
revetShifts.set(key, clampRevetShift((revetShifts.get(key) ?? 0) + deltaM));
persistRevetShifts();
const current = revetShifts.get(key) ?? { ...ZERO_ADJUST };
const next: WallAdjust = {
x: clampMove(patch.x ?? current.x),
d: clampMove(patch.d ?? current.d),
// 높이는 0.1 눈금 반올림만 — 재질 한계 절삭은 기하가 하고 되받는다.
h: patch.h === undefined ? current.h : patch.h === null ? null : round1(patch.h),
m: patch.m === undefined ? current.m : patch.m,
};
storeAdjust(key, next);
deps.refreshCard(chainageM);
},
reset: (chainageM, role) => {
+19 -1
View File
@@ -610,8 +610,18 @@
display: none;
}
/* 숫자 입력의 브라우저 스피너 제거(2026-08-22 사용자 — / 버튼으로 조작). */
.b06-structure-panel__count::-webkit-inner-spin-button,
.b06-structure-panel__count::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.b06-structure-panel__count {
width: 3.5em;
-moz-appearance: textfield;
appearance: textfield;
text-align: center;
width: 2.5em;
padding: 0 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
@@ -620,6 +630,14 @@
font-size: var(--text-caption);
}
.b06-structure-panel__hval {
min-width: 3em;
text-align: center;
color: var(--color-text-body);
font-size: var(--text-caption);
align-self: center;
}
.b06-structure-panel__buttons.is-hidden {
display: none;
}
+25
View File
@@ -252,7 +252,32 @@ export const ui_locales_b2 = {
B06_Cross_Revet_Outlet: ["유출 기슭막이", "Outlet revetment"],
B06_Cross_Struct_Label: ["구조물 형식", "Structure type"],
B06_Cross_Revet_Extra: ["추가 기슭막이", "Extra revetment"],
B06_Cross_Revet_Up: ["사면 위로(대각) 1m", "Up along fill slope 1m"],
B06_Cross_Revet_Down: ["사면 아래로(대각) 1m", "Down along fill slope 1m"],
B06_Cross_Revet_DirUp: ["위", "up"],
B06_Cross_Revet_DirDown: ["아래", "down"],
B06_Cross_Revet_Limit_Up: [
"사면 위로는 여기까지입니다 — 최소 성토고·토피(역경사) 한계",
"Cannot move further up the slope — min fill height / cover limit",
],
B06_Cross_Revet_Limit_Down: [
"사면 아래로는 여기까지입니다",
"Cannot move further down the slope",
],
B06_Cross_Height_Label: ["높이", "Height"],
B06_Cross_Height_Minus: ["높이 0.1m", "Height 0.1m"],
B06_Cross_Height_Plus: ["높이 +0.1m", "Height +0.1m"],
B06_Cross_Height_Limit: [
"{mat} 높이 한계 {limit}m — 더 올리려면 재질을 변경하세요",
"{mat} height limit {limit}m — change material to go higher",
],
B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"],
B06_Cross_Mat_Dry: ["메쌓기", "Dry masonry"],
B06_Cross_Mat_Wet: ["찰쌓기", "Wet masonry"],
B06_Cross_Mat_Concrete: ["콘크리트", "Concrete"],
B06_Cross_Extra_Count: ["추가 기슭막이(단)", "Extra revetments"],
B06_Cross_Extra_Add: ["한 단 추가", "Add one tier"],
B06_Cross_Extra_Remove: ["한 단 삭제", "Remove one tier"],
B06_Cross_Extra_Limit: [
"지형상 추가 기슭막이는 {n}단까지만 가능합니다 — 벽이 원지반에 0.5m 이상 묻히면 성토가 필요 없습니다",
"Terrain allows only {n} extra revetment tier(s) — no fill needed once a wall is buried 0.5m+",