사용자 확정(2026-09-07) — 소단은 좌측 별도 폼이 아니라 **다른 옹벽·기슭막이와 같은 자리** (구조물 배치)에서 놓는 것으로 바뀜. 기하는 그대로 두고 **입구만 옮겼음.** · 레지스트리에 **C군(사면안정) 「소단」** 신설. 옵션은 다른 C군 구간형과 같은 꼴 — 길이·기준측점 전·후 + 폭·간격(사면길이)·안쪽 기울기. · ⚠ **소단은 C군 구간형이지만 벽이 아님** — 기슭막이 제원 자리에 얹히지 않게 파이썬·TS 양쪽 벽 필터에서 뺐음. 안 뺐으면 소단을 놓는 순간 「독립 기슭막이」로 그려졌을 것임. · 구조물 목록이 바뀌면 소단 구간을 세션 사본(`berm`)으로 펴고, 달라졌을 때만 재계산함. 계산 계통(브라우저·서버·`USER_TOUCHED_KEYS`·옹벽 의무 판정)은 **한 줄도 안 바꿨음**. · ④에서 만든 좌측 별도 「소단」 패널은 걷어냈음(모듈 삭제). **안쪽 기울기 기본값 2° → 0°** (사용자 재확정). 기울이는 것은 실무이나 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받음. 주석·지식DB 방침도 그에 맞춰 고침. **소단측구**(B군 종단배수)도 같은 꼴로 옵션을 붙였음 — 소단 위에 놓이는 시설이라 폭·간격이 소단을 따르고, 기울기만 **사면 쪽**(소단과 반대). 소단이 실제로 서면서 `PENDING_TYPE_IDS` 빈 칸이 풀려 연장 수량이 다른 구조물과 같은 길로 나옴. 자체검증 — 「근거 없는 기본값 금지」 시험에 두 타입 12개 기본값을 근거와 함께 등재. 소단측구 연장 시험을 「빈 칸」에서 「연장 100m」로 뒤집음. 전체 544 passed · 18 skipped. TS 타입 검사·ruff 통과. 화면 확인은 다음 단계. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
7.9 KiB
TypeScript
202 lines
7.9 KiB
TypeScript
/* =============================================================================
|
|
* common_util/common_util_cross_berm.ts
|
|
* 절토 사면의 **계단(소단) 포함 꼭짓점** — 파이썬 짝 (계획서 3-9)
|
|
*
|
|
* ⚠⚠ 짝: `common_util/common_util_cross_berm.py` — 한쪽만 고치면 화면과 저장본이 갈린다.
|
|
* 거울 시험: `tmp/tests/test_cross_berm_mirror.py`
|
|
*
|
|
* 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다.
|
|
* 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다.
|
|
*
|
|
* 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°.
|
|
* · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**.
|
|
* 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을
|
|
* 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준):
|
|
* 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**.
|
|
* · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정). 기울이는 것은 실무이나
|
|
* 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받는다 — 지식DB 에도 적지 않는다.
|
|
* ========================================================================== */
|
|
|
|
/** 소단 기본값 — 근거는 위 설명. */
|
|
export const BERM_DEFAULT_WIDTH_M = 0.5;
|
|
export const BERM_DEFAULT_INTERVAL_M = 3.0;
|
|
export const BERM_DEFAULT_SLOPE_DEG = 0.0;
|
|
|
|
/** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */
|
|
const STEP_M = 0.05;
|
|
const MAX_REACH_M = 200.0;
|
|
|
|
/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */
|
|
export interface BermSpec {
|
|
widthM: number;
|
|
intervalM: number;
|
|
slopeDeg: number;
|
|
}
|
|
|
|
export function bermSpec(
|
|
widthM = BERM_DEFAULT_WIDTH_M,
|
|
intervalM = BERM_DEFAULT_INTERVAL_M,
|
|
slopeDeg = BERM_DEFAULT_SLOPE_DEG,
|
|
): BermSpec {
|
|
return { widthM, intervalM, slopeDeg };
|
|
}
|
|
|
|
/**
|
|
* 짝: `cut_profile_points`. 절토 사면 꼭짓점 `[[거리, 표고], ...]` — 시작에서 바깥으로.
|
|
*
|
|
* `rockBoundaryZ` 가 null 이면 2단계 절토가 아니라 경사가 하나다. 있으면 경계를 만나는
|
|
* 자리에서 암 → 토사로 **한 번** 꺾는다.
|
|
*
|
|
* ⚠ 한 번만 꺾는 것은 **기존 규칙을 그대로 지킨 것**이다. 여러 번 꺾게 바꾸면 소단이 없는
|
|
* 지금 측점들의 설계도 같이 바뀌므로 별건으로 미룬다(2026-09-07).
|
|
*/
|
|
export function cutProfilePoints(
|
|
startDist: number,
|
|
startZ: number,
|
|
cutRatio: number,
|
|
soilCutRatio: number,
|
|
rockBoundaryZ: ((dist: number) => number) | null,
|
|
berm: BermSpec | null,
|
|
maxReachM: number = MAX_REACH_M,
|
|
multiKnee = false,
|
|
): Array<[number, number]> {
|
|
const points: Array<[number, number]> = [[startDist, startZ]];
|
|
let dist = startDist;
|
|
let elevation = startZ;
|
|
let slantSinceBerm = 0;
|
|
const limit = startDist + maxReachM;
|
|
const bermRise =
|
|
berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0;
|
|
// 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다).
|
|
let inSoil = rockBoundaryZ === null || elevation >= rockBoundaryZ(dist);
|
|
let ratio = rockBoundaryZ !== null && inSoil ? soilCutRatio : cutRatio;
|
|
|
|
while (dist < limit) {
|
|
const rise = STEP_M / ratio;
|
|
const slant = Math.hypot(STEP_M, rise);
|
|
|
|
// ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다.
|
|
if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) {
|
|
const remain = Math.max(berm.intervalM - slantSinceBerm, 0);
|
|
const run = remain / Math.hypot(1, 1 / ratio);
|
|
dist += run;
|
|
elevation += run / ratio;
|
|
points.push([dist, elevation]); // 소단 안쪽 모서리
|
|
dist += berm.widthM;
|
|
elevation += bermRise;
|
|
points.push([dist, elevation]); // 소단 바깥 모서리
|
|
slantSinceBerm = 0;
|
|
continue;
|
|
}
|
|
|
|
const nextDist = dist + STEP_M;
|
|
const nextZ = elevation + rise;
|
|
|
|
// ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다.
|
|
// `multiKnee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙).
|
|
if (rockBoundaryZ !== null && (multiKnee || !inSoil)) {
|
|
const diffNow = elevation - rockBoundaryZ(dist);
|
|
const diffNext = nextZ - rockBoundaryZ(nextDist);
|
|
const crossed = !inSoil ? diffNext >= 0 : diffNext < 0;
|
|
if (crossed) {
|
|
const span = diffNext - diffNow;
|
|
let share = Math.abs(span) > 1e-12 ? -diffNow / span : 0;
|
|
share = Math.min(Math.max(share, 0), 1);
|
|
const kneeDist = dist + STEP_M * share;
|
|
const kneeZ = elevation + rise * share;
|
|
slantSinceBerm += Math.hypot(kneeDist - dist, kneeZ - elevation);
|
|
dist = kneeDist;
|
|
elevation = kneeZ;
|
|
points.push([dist, elevation]); // 무릎
|
|
inSoil = !inSoil;
|
|
ratio = inSoil ? soilCutRatio : cutRatio;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
dist = nextDist;
|
|
elevation = nextZ;
|
|
slantSinceBerm += slant;
|
|
}
|
|
|
|
points.push([dist, elevation]);
|
|
return dedupe(points);
|
|
}
|
|
|
|
/**
|
|
* 짝: `fill_profile_points`. 성토 사면 꼭짓점 — 시작에서 바깥으로 **내려간다**.
|
|
*
|
|
* 절토와 달리 경사가 하나뿐이라 무릎이 없다. 소단 규칙은 같다.
|
|
*/
|
|
export function fillProfilePoints(
|
|
startDist: number,
|
|
startZ: number,
|
|
fillRatio: number,
|
|
berm: BermSpec | null,
|
|
maxReachM: number = MAX_REACH_M,
|
|
): Array<[number, number]> {
|
|
const points: Array<[number, number]> = [[startDist, startZ]];
|
|
let dist = startDist;
|
|
let elevation = startZ;
|
|
let slantSinceBerm = 0;
|
|
const limit = startDist + maxReachM;
|
|
const bermRise =
|
|
berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0;
|
|
const ratio = Math.max(fillRatio, 1e-6);
|
|
|
|
while (dist < limit) {
|
|
const drop = STEP_M / ratio;
|
|
const slant = Math.hypot(STEP_M, drop);
|
|
if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) {
|
|
const remain = Math.max(berm.intervalM - slantSinceBerm, 0);
|
|
const run = remain / Math.hypot(1, 1 / ratio);
|
|
dist += run;
|
|
elevation -= run / ratio;
|
|
points.push([dist, elevation]); // 소단 안쪽 모서리
|
|
dist += berm.widthM;
|
|
elevation += bermRise;
|
|
points.push([dist, elevation]); // 소단 바깥 모서리
|
|
slantSinceBerm = 0;
|
|
continue;
|
|
}
|
|
dist += STEP_M;
|
|
elevation -= drop;
|
|
slantSinceBerm += slant;
|
|
}
|
|
|
|
points.push([dist, elevation]);
|
|
return dedupe(points);
|
|
}
|
|
|
|
/** 같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게. */
|
|
function dedupe(points: Array<[number, number]>): Array<[number, number]> {
|
|
const out: Array<[number, number]> = [];
|
|
for (const point of points) {
|
|
const last = out[out.length - 1];
|
|
if (last && Math.abs(point[0] - last[0]) < 1e-9 && Math.abs(point[1] - last[1]) < 1e-9) {
|
|
continue;
|
|
}
|
|
out.push(point);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 짝: `elevation_at`. 꼭짓점 목록에서 거리 하나의 표고(사이는 직선, 끝은 연장). */
|
|
export function elevationAt(points: Array<[number, number]>, dist: number): number {
|
|
if (points.length === 0) return 0;
|
|
if (dist <= points[0][0]) return points[0][1];
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const [x0, z0] = points[index - 1];
|
|
const [x1, z1] = points[index];
|
|
if (dist > x1 + 1e-12) continue;
|
|
const span = x1 - x0;
|
|
if (span <= 1e-12) return z1;
|
|
return z0 + (z1 - z0) * ((dist - x0) / span);
|
|
}
|
|
const [x0, z0] = points.length > 1 ? points[points.length - 2] : points[points.length - 1];
|
|
const [x1, z1] = points[points.length - 1];
|
|
const span = x1 - x0;
|
|
if (span <= 1e-12) return z1;
|
|
return z1 + ((z1 - z0) / span) * (dist - x1);
|
|
}
|