fix(B06): 원지반 제약 끊음 — 다단 자동 자리 사면 1.0 m · 설계선 끝 수치 판정 · 전면 기울기 칸 아니면 성토 · 「지형에 막힘」 잔재 지움(91-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-08 12:12:16 +09:00
co-authored by Claude Opus 5.5
parent 8c04d520f4
commit de93c7b248
14 changed files with 360 additions and 471 deletions
@@ -191,9 +191,6 @@ export function appendCulvertOverlay(
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)`
: ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)`) +
(wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "") +
(wall.shiftBlockedM
? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)`
: "") +
(wall.shiftFloorM
? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` +
` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임`
@@ -169,6 +169,8 @@ export function pipeWallMinPureHeight(diameterM: number): number {
export const WALL_MIN_PURE_HEIGHT_M = 0.1;
/** 일반(추가) 기슭막이 기본 높이(m). */
export const EXTRA_WALL_DEFAULT_HEIGHT_M = 1.5;
/** 다단 자동 자리 — 앞 단 시작점 → 벽 상단 1:1.2 사면 길이(m) · 원지반 무관(91-3 ② 브레인). */
export const EXTRA_TIER_SLOPE_M = 1.0;
/**
* 성토 비탈 기울기(1:n) 허용 범위 — 지식DB `성토_비탈면.md` §1:
@@ -4,14 +4,14 @@
*
* 다단 기슭막이는 **1:1.2 고정 물매에서 안정적으로 성토고를 올리는 수단**이다
* (2026-08-22 사용자 확정 규칙):
* · 기슭막이 사이 성토사면은 **정확히 1:1.2** — 벽 높이를 자리에 맞춰 역산한다.
* · 기슭막이 사이 성토사면은 **정확히 1:1.2** · 자동 자리 = 사면 1.0 m 뒤 벽 상단(원지반 무관 · 91-3).
* · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**.
* · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:n · 표준경사)의
* 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다).
* · 그 시작점이 원지반 아래면(벽이 0.5m 이상 묻힘) **성토 불필요** — 다단 종료.
* ========================================================================== */
import {
EXTRA_TIER_SLOPE_M,
EXTRA_WALL_DEFAULT_HEIGHT_M,
PIPE_CONNECT_GRADE,
FILL_SLOPE_MAX_LENGTH_M,
@@ -263,18 +263,15 @@ function trailingFill(
* 유출측 성토부선·다단 기슭막이 일괄 계산 (2026-08-22 4축 조작 체계).
*
* 단별 벽 높이 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)
* 항상 src에서 내려오는 **1:1.2 성토선 위** — 자동 자리는 그 선을 사면 길이
* `EXTRA_TIER_SLOPE_M` 만큼 내려간 곳에 벽 상단(원지반 무관 · 91-3).
* 조작: 좌우 x = src 높이의 **수평 선반**을 끼워 평행이동(물매 불변, 상단 표고
* 불변 — 윗단 기준 0.5m 수평 구현), 상하 d = 성토선을 타는 대각(수평 성분).
* 위(−d) 한계 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단). 바닥은 상단−h−0.5로
* 지반과 무관 — 못 닿으면 뜨고(floatGap 경고), 지나면 묻힌다.
* 요청 단 수보다 지형이 허락하는 단이 적으면 되는 만큼만 세운다.
* 지반과 무관 — 못 닿으면 뜨고(floatGap 경고), 지나면 묻힌다. 요청 단 수 그대로 세운다.
*/
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
const { outward, groundAt, limitOffset } = input;
const thickness = REVET_THICKNESS_M;
/** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */
const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => {
const walls: WallLayout[] = [];
@@ -299,43 +296,10 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const exposed = height - REVET_EMBED_DEPTH_M;
// 전면 기울기 — 형태·요청 높이로 표준경사(2026-09-14 B5). 자리 닫힌식의 분모도 따라감.
const lean = input.leanFor?.(form, height) ?? REVET_LEAN_RATIO;
const heightDenominator = 1 - lean / 2 / FILL_SLOPE_RATIO_MIN;
/** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */
const heightAt = (x: number): number => {
const run = (x - src.offset) * outward;
return (
(src.elevation - groundAt(x) - (run - 0.25 * thickness) / FILL_SLOPE_RATIO_MIN) /
heightDenominator
);
};
// 자동 자리 = heightAt이 사용자 높이 h에 처음 닿는 지점(0.05m 스캔 후 선형 보간).
// 지형이 완만해 h까지 못 크면 **가장 깊이 앉는 지점**(heightAt 최대)으로 폴백 —
// 바닥은 상단−h−0.5라 지반과 어긋나도 된다(뜨면 경고, 묻히면 그대로).
const step = 0.05;
const span = (limitOffset - src.offset) * outward;
let autoOffset: number | null = null;
let bestOffset: number | null = null;
let bestHeight = 0.05; // 이 이하로만 앉는 지형이면 단을 세우지 않는다
let previousShort = 0; // 직전 스캔점의 h 부족량(heightAt < h) — 보간용
for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) {
const x = src.offset + outward * t;
const gap = heightAt(x) - exposed;
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;
}
}
// 앉을 지형이 없으면(묻힘) 이음선이 시작점 바로 밖 — 원지반과 무관하게 선다.
autoOffset ??= bestOffset ?? src.offset + outward * jointRunOf(exposed, lean);
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed, lean);
// 자동 자리 = 앞 단 시작점에서 1:1.2 사면 EXTRA_TIER_SLOPE_M 뒤 벽 상단 — 원지반 무관(91-3 ②).
const autoJointRun =
(EXTRA_TIER_SLOPE_M * FILL_SLOPE_RATIO_MIN) / Math.hypot(1, FILL_SLOPE_RATIO_MIN);
const autoOffset = src.offset + outward * (autoJointRun + jointRunOf(exposed, lean));
/** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */
const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN;
// 관통 금지: 상단 ≤ 윗단 하단 → 그만큼은 반드시 내려간다.
@@ -9,7 +9,6 @@ import {
FILL_MIN_RISE_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MIN,
REVET_HEIGHT_STEP_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
REVET_TRAP_TOP_M,
@@ -217,21 +216,6 @@ export function outletSlopeFactory(
};
}
/**
* 손으로 민 벽 자리 한계 — 안쪽 당김은 **1:1.2가 성립하는 가장 안쪽 자리**(innerLimit
* = 자동 자리)에서 멈춘다(2026-08-22 사용자 확정 — 우선순위: 배관 연장 > 높이 증가 >
* 바닥 띄움. 당겨서 물매가 깨질 바에는 관을 길게 유지한다). 1:1.2 자리가 아예 없는
* 지형 폴백에서는 innerLimit이 노견까지 내려오고, 그때만 높이 증가·바닥 띄움
* (`solveWallVertical`)이 발동한다. 바깥 밀기는 막지 않는다(노폭 연장으로 흡수).
*/
export function clampWallOffset(
wantedOffset: number,
innerLimitOffset: number,
outward: number,
): number {
return (wantedOffset - innerLimitOffset) * outward < 0 ? innerLimitOffset : wantedOffset;
}
/**
* 기슭막이 **자동 자리 = 노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정).
* 노견 끝에서 바깥으로 훑어, 표준 높이 벽으로 ① 성토고 하한(FILL_MIN_RISE_M)과
@@ -279,44 +263,6 @@ export interface WallVertical {
floatGapM: number;
}
/**
* 벽 자리에 맞는 **수직 제원**을 푼다(2026-08-22 사용자 확정 — 최소 노견 유지 규칙).
* 벽 상단(이음선 상단점)은 기본 노견에서 내려오는 **1:1.2 사면선 위**에 놓여야 한다.
* ① 자리가 안쪽이라 표준 높이(관경+여유고)로 사면선에 못 닿으면 → 높이를 키운다.
* ② 형태별 높이 한계(찰 3.0/메 2.0)에 걸리면 → 바닥을 원지반에서 띄운다
* (뜬 공간은 별도 지지 구조물 — 추가 예정).
* ③ 자리가 바깥이라 사면선이 표준 높이 아래로 지나면 → 표준 높이 유지, 모자란
* 물매는 `fillSlopeOf`가 노폭 연장으로 맞춘다(기존 규칙).
* 높이는 0.1m 눈금 올림(2026-08-21 확정)이라 상단이 사면선보다 반 눈금쯤 높을 수 있다.
*/
export function solveWallVertical(
anchorOffset: number,
edge: { offset_m: number; elevation_m: number },
outward: number,
groundBase: number,
minHeight: number,
limitHeight: number,
lean: number = REVET_LEAN_RATIO,
): WallVertical {
const gridUp = (value: number): number =>
Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M;
let height = minHeight;
let desiredTop = groundBase + minHeight;
for (let i = 0; i < 4; i += 1) {
const joint = anchorOffset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
const run = Math.max(0, (joint - edge.offset_m) * outward);
desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN;
const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight);
if (Math.abs(next - height) < 1e-9) {
height = next;
break;
}
height = next;
}
const floatGap = Math.max(0, desiredTop - limitHeight - groundBase);
return { height, baseElevation: groundBase + floatGap, floatGapM: floatGap };
}
/** 교점을 마감면 **선분 안**으로 자른다 — 선분 밖(벽 하단 아래 등)으로 새지 않게. */
export function clampToFace(
point: OffsetPoint,
@@ -71,12 +71,9 @@ export interface WallLayout {
* 별도 지지 구조물로 메운다(추가 예정). 0이면 지반 위(근입 0.5m).
*/
floatGapM: number;
/** 사용자가 요청한 좌우 이동을 **지형이 막아 못 나간** 거리(m, 2026-09-06).
* 0보다 크면 그 자리에서 더 나가면 벽이 원지반에 묻힌다는 뜻이라 툴팁으로 알린다. */
shiftBlockedM?: number;
/** 좌우 이동의 **하한**(m, 2026-09-07). 선반 길이가 음수가 되지 않게 x 는 최소
* `1.2 × 상하 내림`까지 따라 나간다. 요청이 이 값보다 작으면 눌러도 벽이 안 움직이고,
* 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0으로 나온다 — 그 까닭을 툴팁으로 알린다. */
* `1.2 × 상하 내림`까지 따라 나간다. 요청이 이 값보다 작으면 눌러도 벽이 안 움직임 —
* 그 까닭을 툴팁으로 알린다. */
shiftFloorM?: number;
/** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */
material: RevetMaterial;
@@ -151,31 +151,9 @@ export interface CulvertLink {
* 옆 측점에 서야 한다). 고정 기본 10m(5/5)는 소유 벽 제원조차 없을 때의 마지막 값이다.
* 손으로 한 번 잡은 단은 그 값으로 고정된다 — 계곡부·능선부에서 단마다 연장이 다르다.
*/
/** 「단이 못 섰다」 안내를 이미 낸 자리 — 다시 그릴 때마다 뜨지 않게 한 번만 낸다.
* 키에 세워진 단 수를 넣어, 지형·조작이 바뀌어 결과가 달라지면 다시 알린다. */
const extraLimitNotified = new Set<string>();
/** 벽마다 직전에 적용된 자리 — 자리 조작이 한계에 막혀 벽이 그대로일 때만 알리려고 둠. */
const lastApplied = new Map<string, WallAdjust>();
/** 요청 단 수보다 적게 섰음을 알린다 — 조정창을 열지 않은 경로(좌측 폼 등)에서도 뜬다.
* 종전에는 `activeRevet` 이 그 벽일 때만 떠서, 다른 길로 단 수를 바꾸면 조용히 잘렸다
* (2026-09-07). 문구는 종전 그대로 쓴다. */
function noticeExtraLimit(chainageM: number, side: "outlet" | "basin", built: number): void {
const key = `${chainageM.toFixed(2)}|${side}|${built}`;
if (extraLimitNotified.has(key)) return;
extraLimitNotified.add(key);
showToast(L("B06_Cross_Extra_Limit").replace("{n}", String(built)), "info");
}
/** 요청대로 다 선 자리는 안내 기록을 지운다 — 다음에 또 모자라면 다시 알린다. */
function clearExtraLimitNotice(chainageM: number, side: "outlet" | "basin"): void {
const head = `${chainageM.toFixed(2)}|${side}|`;
for (const key of [...extraLimitNotified]) {
if (key.startsWith(head)) extraLimitNotified.delete(key);
}
}
export function tierSpanOf(section: CrossSection, key: string): StructureSpan {
const stored = section.design?.extra_spans?.[key];
if (stored) return { beforeM: Math.max(stored.before_m, 0), afterM: Math.max(stored.after_m, 0) };
@@ -512,23 +490,6 @@ export function computeCardCulvert(
inletStructure.updateAdjust(section.chainage_m, applied);
}
}
if (extraWalls && extraWalls.countFor(section, "basin") > layout.basinExtras.length) {
// 유입측(집수정 계류측·독립 기슭막이)도 유출측과 같은 규칙으로 알린다 — 종전에는
// 조용히 잘려서 +를 눌러도 아무 일이 없는 것처럼 보였다(2026-08-29 사용자 지적:
// 우측 독립 기슭막이에서 추가2가 안 서는데 토스트가 없다).
noticeExtraLimit(section.chainage_m, "basin", layout.basinExtras.length);
extraWalls.syncCount(section.chainage_m, layout.basinExtras.length, "basin");
} else if (extraWalls) {
clearExtraLimitNotice(section.chainage_m, "basin");
}
if (extraWalls && extraWalls.countFor(section, "outlet") > layout.extraWalls.length) {
// 요청 단 수보다 지형이 허락하는 단이 적다(벽이 원지반에 0.5m 이상 묻히면 성토
// 불필요 — 그 아래 단은 못 세운다). 조작 중일 때만 가능한 단 수를 토스트로 알린다.
noticeExtraLimit(section.chainage_m, "outlet", layout.extraWalls.length);
extraWalls.syncCount(section.chainage_m, layout.extraWalls.length, "outlet");
} else if (extraWalls) {
clearExtraLimitNotice(section.chainage_m, "outlet");
}
if (revetOffset) {
const roles: Array<[RevetKey, WallAdjust]> = [
["inlet", layout.revetShift.inlet],
+1 -259
View File
@@ -16,20 +16,17 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type {
CrossDesign,
CrossSection,
DitchSide,
DitchType,
GroundType,
SectionMode,
SectionSample,
} from "./B06_Section_Api_Fetch";
export {
appendPavementOverlay,
appendRockBoundaryOverlay,
} from "./B06_Section_UI_Cross_Design_Surface";
const SVG_NS = "http://www.w3.org/2000/svg";
export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line";
/** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */
interface ReflowCard {
@@ -524,261 +521,6 @@ export function buildDesignControls(
return { bar, groundSegment };
}
/**
* 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는
* 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다.
*
* `trim`이 오면 그 offset 범위 밖 구간은 그리지 않는다 — 배수관 측점에서 기슭막이가
* 성토를 받치므로 벽과 교차된 이후의 성토 경사는 끊는다(2026-08-20 사용자 확정).
* 경계에 걸린 구간은 경계 지점까지 잘라 그린다.
*/
export function appendCrossDesignOverlay(
svg: SVGElement,
design: CrossDesign,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
groundSamples: SectionSample[],
trim?: {
minOffset: number;
maxOffset: number;
minElevation?: number;
maxElevation?: number;
minSlope?: { points: Array<{ offset: number; elevation: number }> };
maxSlope?: { points: Array<{ offset: number; elevation: number }> };
},
// 노폭 라벨 고정 자리(B06 카드 — X 축 라벨 바로 위 가운데). 없으면 노면 위.
labelAt?: { layer: SVGElement; x: number; y: number },
): void {
const line = design.design_line;
if (!line || line.length < 2) return;
// 지면선 표고 보간기(정렬된 유효 샘플, 범위 밖 끝값 클램프).
const ground = groundSamples
.filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m))
.map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const groundAt = (offset: number): number | null => {
if (!ground.length) return null;
if (offset <= ground[0].offset) return ground[0].elevation;
const last = ground[ground.length - 1];
if (offset >= last.offset) return last.elevation;
for (let i = 1; i < ground.length; i += 1) {
if (offset > ground[i].offset) continue;
const a = ground[i - 1];
const b = ground[i];
const span = b.offset - a.offset;
if (span <= 0) return b.elevation;
return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span);
}
return last.elevation;
};
// 각 설계선 점이 지면선과 픽셀 단위로 겹치는지 판정한다.
const OVERLAP_PX = 2;
const overlap = line.map((point) => {
const g = groundAt(point.offset_m);
if (g === null) return false;
return Math.abs(toDisplayY(point.elevation_m) - toDisplayY(g)) <= OVERLAP_PX;
});
// 사면 끝(계획선이 원지반과 처음 만나는 지점) **바깥**은 계획선을 그리지 않는다 —
// 그 너머는 손대지 않는 원지반이라 지반선이 이미 표현한다(2026-08-20 사용자 지적:
// 12+0.0 좌측 성토부에서 접점 이후 계획선이 계속 그려짐).
// 탐색은 **노견 바깥**에서 시작한다 — 노면·측구·노견을 지나는 계획선은 지반과
// 우연히 겹쳐도 지워선 안 되고, 노견 이후 사면이 지반과 만나는 2차 접점만 종료점이다
// (2026-08-20 사용자 ①).
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
const edgesForTrim = design.road_edges;
const leftEdge = edgesForTrim
? Math.max(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m)
: 0;
const rightEdge = edgesForTrim
? Math.min(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m)
: 0;
// 측구는 노견 **바깥**에 붙는다(road_edges 밖). 측구가 있는 쪽은 측구 바깥 끝까지
// 보호하고 그 다음부터 접점을 찾는다 — 노견에서 바로 찾으면 측구가 통째로 잘린다
// (2026-08-20 사용자: "측구 이후 성토부가 존재하는 경사로의 1차 교차점까지 보호").
const ditchSpec = design.ditch;
const ditchWidth =
design.ditch_enabled === false || !ditchSpec || ditchSpec.type === "none"
? 0
: ditchSpec.type === "standard"
? ditchSpec.top_width_m
: ditchSpec.width_m;
// 좌표 규약: +offset = 좌측. ditch_side "left" = 큰 offset 쪽.
const protectMax = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0);
const protectMin = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0);
const meetOffset = (startOffset: number, step: number): number | null => {
const indices =
step > 0 ? sorted.map((_, i) => i) : sorted.map((_, i) => sorted.length - 1 - i);
for (const i of indices) {
const point = sorted[i];
// 노견 안쪽(도로 구간)은 건너뛴다.
if (step > 0 ? point.offset_m <= startOffset : point.offset_m >= startOffset) continue;
const g = groundAt(point.offset_m);
if (g === null) continue;
if (Math.abs(toDisplayY(point.elevation_m) - toDisplayY(g)) <= OVERLAP_PX)
return point.offset_m;
}
return null;
};
const meetLeft = meetOffset(protectMin, -1); // 작은 offset 쪽(측구까지 지난 뒤)
const meetRight = meetOffset(protectMax, 1); // 큰 offset 쪽(측구까지 지난 뒤)
// 트림 범위로 구간 하나를 자른다. 완전히 밖이면 null, 걸치면 경계에서 보간해 끊는다.
const clipSegment = (
a: { offset_m: number; elevation_m: number },
b: { offset_m: number; elevation_m: number },
):
| [{ offset_m: number; elevation_m: number }, { offset_m: number; elevation_m: number }]
| null => {
// 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위.
// 배수관 레이아웃이 사면 폴리라인(min/maxSlope)을 준 쪽은 **그 시작점(노견)에서**
// 설계선을 끊는다 — 그 바깥은 폴리라인이 그리므로 이중선이 되고, 트림 경계에서
// 표고를 벽 상단으로 스냅하면서 수직으로 튀는 선이 남는다(2026-08-22 사용자 지적:
// 집수정을 좌우로 옮기면 벽 상단이 노견 표고 그대로라 스냅 폭이 그만큼 커진다).
const minSlopeStart = trim?.minSlope?.points[0]?.offset;
const maxSlopeStart = trim?.maxSlope?.points[0]?.offset;
const minOffset = Math.max(
trim?.minOffset ?? -Infinity,
meetLeft ?? -Infinity,
minSlopeStart ?? -Infinity,
);
const maxOffset = Math.min(
trim?.maxOffset ?? Infinity,
meetRight ?? Infinity,
maxSlopeStart ?? Infinity,
);
if (!Number.isFinite(minOffset) && !Number.isFinite(maxOffset)) return [a, b];
const effective = { minOffset, maxOffset };
const lo = Math.min(a.offset_m, b.offset_m);
const hi = Math.max(a.offset_m, b.offset_m);
if (hi <= effective.minOffset || lo >= effective.maxOffset) return null;
const at = (offset: number) => {
const span = b.offset_m - a.offset_m;
const t = Math.abs(span) > 1e-9 ? (offset - a.offset_m) / span : 0;
return { offset_m: offset, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t };
};
// 배수관 트림 경계에서는 끝단 표고를 **벽 이음선 상단점**에 맞춰 내린다 — 벽 높이가
// 관경+여유고로 고정된 뒤로는 설계 사면선 표고와 어긋나 사면선 끝이 벽에서 떨어진다
// (2026-08-21 사용자 ②). 사면 끝 접점 트림이 이긴 경계에는 적용하지 않는다.
const snap = (
p: { offset_m: number; elevation_m: number },
elevation: number | undefined,
wins: boolean,
) => (elevation != null && wins ? { offset_m: p.offset_m, elevation_m: elevation } : p);
const minIsCulvert = trim != null && Math.abs(effective.minOffset - trim.minOffset) < 1e-9;
const maxIsCulvert = trim != null && Math.abs(effective.maxOffset - trim.maxOffset) < 1e-9;
const clamp = (p: { offset_m: number; elevation_m: number }) =>
p.offset_m < effective.minOffset
? snap(at(effective.minOffset), trim?.minElevation, minIsCulvert)
: p.offset_m > effective.maxOffset
? snap(at(effective.maxOffset), trim?.maxElevation, maxIsCulvert)
: p;
return [clamp(a), clamp(b)];
};
// 세그먼트별로 그린다. 지면선과 겹치는 구간(= 계획선이 원지반과 차이가 없는 구간)은
// **그리지 않는다** — 지반선이 이미 같은 자리를 표현하므로 정보가 없다
// (2026-08-20 사용자 지시: 보라색 점선 삭제).
// 단 **노면·노견·측구부터 1차 접점까지는 언제나 그린다** — 계획고가 지반과 우연히
// 몇 ㎝ 차이면 노면 계획선이 통째로 끊겨 보이고(8·10·10+0.9), 노견 끝 성토고가
// 4㎝ 정도면 접점까지의 짧은 사면 조각도 사라진다(9+0.0 — 2026-08-20 사용자 지적).
// 접점을 못 찾은 쪽은 보호 구간을 노견·측구까지로 둔다.
const keepMin = meetLeft ?? protectMin;
const keepMax = meetRight ?? protectMax;
const isProtected = (a: { offset_m: number }, b: { offset_m: number }): boolean =>
Math.max(a.offset_m, b.offset_m) > keepMin && Math.min(a.offset_m, b.offset_m) < keepMax;
for (let i = 1; i < line.length; i += 1) {
if (overlap[i - 1] && overlap[i] && !isProtected(line[i - 1], line[i])) continue;
const clipped = clipSegment(line[i - 1], line[i]);
if (!clipped) continue;
const seg = document.createElementNS(SVG_NS, "line");
seg.setAttribute("x1", String(x(clipped[0].offset_m)));
seg.setAttribute("y1", String(toDisplayY(clipped[0].elevation_m)));
seg.setAttribute("x2", String(x(clipped[1].offset_m)));
seg.setAttribute("y2", String(toDisplayY(clipped[1].elevation_m)));
seg.setAttribute("class", "b06-chart__design-cross");
svg.append(seg);
}
// 배수관 측점 성토 사면 — 백엔드 설계선(1:1.2 고정) 대신 **배수관 레이아웃이 준 직선**
// 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일
// 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도).
for (const segment of [trim?.minSlope, trim?.maxSlope]) {
if (!segment || segment.points.length < 2) continue;
// 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. 물매가 1:1.2 고정이라
// 벽이 밖으로 나간 만큼 노면이 연장된다(2026-08-21 사용자 확정).
for (let i = 1; i < segment.points.length; i += 1) {
const a = segment.points[i - 1];
const b = segment.points[i];
const seg = document.createElementNS(SVG_NS, "line");
seg.setAttribute("x1", String(x(a.offset)));
seg.setAttribute("y1", String(toDisplayY(a.elevation)));
seg.setAttribute("x2", String(x(b.offset)));
seg.setAttribute("y2", String(toDisplayY(b.elevation)));
seg.setAttribute("class", "b06-chart__design-cross");
svg.append(seg);
}
}
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
// 구분이 안 되는 경계를 표시한다.
const edges = design.carriageway_edges;
if (edges) {
const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => {
const cx = x(offsetM);
const cy = toDisplayY(elevationM);
const tick = document.createElementNS(SVG_NS, "line");
tick.setAttribute("x1", String(cx));
tick.setAttribute("y1", String(cy - half));
tick.setAttribute("x2", String(cx));
tick.setAttribute("y2", String(cy + half));
tick.setAttribute("class", className);
svg.append(tick);
};
for (const edge of [edges.left, edges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6);
}
// 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어
// **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로
// 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다.
const roadEdges = design.road_edges;
if (roadEdges) {
for (const edge of [roadEdges.left, roadEdges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4);
}
}
// 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다.
// 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0);
const standardWidth = design.carriageway_standard_width_m;
const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null;
const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null;
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute(
"x",
String(labelAt?.x ?? (x(edges.left.offset_m) + x(edges.right.offset_m)) / 2),
);
label.setAttribute("y", String(labelAt?.y ?? toDisplayY(edges.left.elevation_m) - 6));
label.setAttribute("class", "b06-chart__carriageway-label");
const widthText =
widened > 0.001 && typeof standardWidth === "number"
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})`
: `노폭 ${design.carriageway_width_m.toFixed(2)}m`;
// 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다.
label.textContent =
shoulderLeft != null && shoulderRight != null
? `${widthText} · 노견 ${
Math.abs(shoulderLeft - shoulderRight) < 0.005
? `${shoulderLeft.toFixed(2)}m`
: `좌 ${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m`
}`
: widthText;
(labelAt?.layer ?? svg).append(label);
}
}
/**
* 암 경계선(지면선 복사 + 상하 오프셋, 점선)을 겹쳐 그린다.
* 계획선(설계선)이 아니라 **지반선(지면선)**을 복사해 이동하는 것이 규칙이다.
@@ -0,0 +1,263 @@
/* =============================================================================
* B06_Section_UI_Cross_Design_Line.ts
* 횡단 설계선 오버레이 — `_Cross_Design.ts` 에서 분리(700줄 제한 · 91-3).
* 사면 끝 = 설계선이 원지반과 처음 만나는 자리 · 수치(0.01 m)로 판정 — 화면 축척과 무관.
* ========================================================================== */
import type { CrossDesign, SectionSample } from "./B06_Section_Api_Fetch";
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는
* 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다.
*
* `trim`이 오면 그 offset 범위 밖 구간은 그리지 않는다 — 배수관 측점에서 기슭막이가
* 성토를 받치므로 벽과 교차된 이후의 성토 경사는 끊는다(2026-08-20 사용자 확정).
* 경계에 걸린 구간은 경계 지점까지 잘라 그린다.
*/
export function appendCrossDesignOverlay(
svg: SVGElement,
design: CrossDesign,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
groundSamples: SectionSample[],
trim?: {
minOffset: number;
maxOffset: number;
minElevation?: number;
maxElevation?: number;
minSlope?: { points: Array<{ offset: number; elevation: number }> };
maxSlope?: { points: Array<{ offset: number; elevation: number }> };
},
// 노폭 라벨 고정 자리(B06 카드 — X 축 라벨 바로 위 가운데). 없으면 노면 위.
labelAt?: { layer: SVGElement; x: number; y: number },
): void {
const line = design.design_line;
if (!line || line.length < 2) return;
// 지면선 표고 보간기(정렬된 유효 샘플, 범위 밖 끝값 클램프).
const ground = groundSamples
.filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m))
.map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const groundAt = (offset: number): number | null => {
if (!ground.length) return null;
if (offset <= ground[0].offset) return ground[0].elevation;
const last = ground[ground.length - 1];
if (offset >= last.offset) return last.elevation;
for (let i = 1; i < ground.length; i += 1) {
if (offset > ground[i].offset) continue;
const a = ground[i - 1];
const b = ground[i];
const span = b.offset - a.offset;
if (span <= 0) return b.elevation;
return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span);
}
return last.elevation;
};
// 각 설계선 점이 지면선과 겹치는지 — 수치(0.01 m)로 판정 · 화면 축척(줌)과 무관(91-3).
const OVERLAP_M = 0.01;
const overlap = line.map((point) => {
const g = groundAt(point.offset_m);
if (g === null) return false;
return Math.abs(point.elevation_m - g) <= OVERLAP_M;
});
// 사면 끝(계획선이 원지반과 처음 만나는 지점) **바깥**은 계획선을 그리지 않는다 —
// 그 너머는 손대지 않는 원지반이라 지반선이 이미 표현한다(2026-08-20 사용자 지적:
// 12+0.0 좌측 성토부에서 접점 이후 계획선이 계속 그려짐).
// 탐색은 **노견 바깥**에서 시작한다 — 노면·측구·노견을 지나는 계획선은 지반과
// 우연히 겹쳐도 지워선 안 되고, 노견 이후 사면이 지반과 만나는 2차 접점만 종료점이다
// (2026-08-20 사용자 ①).
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
const edgesForTrim = design.road_edges;
const leftEdge = edgesForTrim
? Math.max(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m)
: 0;
const rightEdge = edgesForTrim
? Math.min(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m)
: 0;
// 측구는 노견 **바깥**에 붙는다(road_edges 밖). 측구가 있는 쪽은 측구 바깥 끝까지
// 보호하고 그 다음부터 접점을 찾는다 — 노견에서 바로 찾으면 측구가 통째로 잘린다
// (2026-08-20 사용자: "측구 이후 성토부가 존재하는 경사로의 1차 교차점까지 보호").
const ditchSpec = design.ditch;
const ditchWidth =
design.ditch_enabled === false || !ditchSpec || ditchSpec.type === "none"
? 0
: ditchSpec.type === "standard"
? ditchSpec.top_width_m
: ditchSpec.width_m;
// 좌표 규약: +offset = 좌측. ditch_side "left" = 큰 offset 쪽.
const protectMax = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0);
const protectMin = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0);
const meetOffset = (startOffset: number, step: number): number | null => {
const indices =
step > 0 ? sorted.map((_, i) => i) : sorted.map((_, i) => sorted.length - 1 - i);
for (const i of indices) {
const point = sorted[i];
// 노견 안쪽(도로 구간)은 건너뛴다.
if (step > 0 ? point.offset_m <= startOffset : point.offset_m >= startOffset) continue;
const g = groundAt(point.offset_m);
if (g === null) continue;
if (Math.abs(point.elevation_m - g) <= OVERLAP_M) return point.offset_m;
}
return null;
};
const meetLeft = meetOffset(protectMin, -1); // 작은 offset 쪽(측구까지 지난 뒤)
const meetRight = meetOffset(protectMax, 1); // 큰 offset 쪽(측구까지 지난 뒤)
// 트림 범위로 구간 하나를 자른다. 완전히 밖이면 null, 걸치면 경계에서 보간해 끊는다.
const clipSegment = (
a: { offset_m: number; elevation_m: number },
b: { offset_m: number; elevation_m: number },
):
| [{ offset_m: number; elevation_m: number }, { offset_m: number; elevation_m: number }]
| null => {
// 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위.
// 배수관 레이아웃이 사면 폴리라인(min/maxSlope)을 준 쪽은 **그 시작점(노견)에서**
// 설계선을 끊는다 — 그 바깥은 폴리라인이 그리므로 이중선이 되고, 트림 경계에서
// 표고를 벽 상단으로 스냅하면서 수직으로 튀는 선이 남는다(2026-08-22 사용자 지적:
// 집수정을 좌우로 옮기면 벽 상단이 노견 표고 그대로라 스냅 폭이 그만큼 커진다).
const minSlopeStart = trim?.minSlope?.points[0]?.offset;
const maxSlopeStart = trim?.maxSlope?.points[0]?.offset;
const minOffset = Math.max(
trim?.minOffset ?? -Infinity,
meetLeft ?? -Infinity,
minSlopeStart ?? -Infinity,
);
const maxOffset = Math.min(
trim?.maxOffset ?? Infinity,
meetRight ?? Infinity,
maxSlopeStart ?? Infinity,
);
if (!Number.isFinite(minOffset) && !Number.isFinite(maxOffset)) return [a, b];
const effective = { minOffset, maxOffset };
const lo = Math.min(a.offset_m, b.offset_m);
const hi = Math.max(a.offset_m, b.offset_m);
if (hi <= effective.minOffset || lo >= effective.maxOffset) return null;
const at = (offset: number) => {
const span = b.offset_m - a.offset_m;
const t = Math.abs(span) > 1e-9 ? (offset - a.offset_m) / span : 0;
return { offset_m: offset, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t };
};
// 배수관 트림 경계에서는 끝단 표고를 **벽 이음선 상단점**에 맞춰 내린다 — 벽 높이가
// 관경+여유고로 고정된 뒤로는 설계 사면선 표고와 어긋나 사면선 끝이 벽에서 떨어진다
// (2026-08-21 사용자 ②). 사면 끝 접점 트림이 이긴 경계에는 적용하지 않는다.
const snap = (
p: { offset_m: number; elevation_m: number },
elevation: number | undefined,
wins: boolean,
) => (elevation != null && wins ? { offset_m: p.offset_m, elevation_m: elevation } : p);
const minIsCulvert = trim != null && Math.abs(effective.minOffset - trim.minOffset) < 1e-9;
const maxIsCulvert = trim != null && Math.abs(effective.maxOffset - trim.maxOffset) < 1e-9;
const clamp = (p: { offset_m: number; elevation_m: number }) =>
p.offset_m < effective.minOffset
? snap(at(effective.minOffset), trim?.minElevation, minIsCulvert)
: p.offset_m > effective.maxOffset
? snap(at(effective.maxOffset), trim?.maxElevation, maxIsCulvert)
: p;
return [clamp(a), clamp(b)];
};
// 세그먼트별로 그린다. 지면선과 겹치는 구간(= 계획선이 원지반과 차이가 없는 구간)은
// **그리지 않는다** — 지반선이 이미 같은 자리를 표현하므로 정보가 없다
// (2026-08-20 사용자 지시: 보라색 점선 삭제).
// 단 **노면·노견·측구부터 1차 접점까지는 언제나 그린다** — 계획고가 지반과 우연히
// 몇 ㎝ 차이면 노면 계획선이 통째로 끊겨 보이고(8·10·10+0.9), 노견 끝 성토고가
// 4㎝ 정도면 접점까지의 짧은 사면 조각도 사라진다(9+0.0 — 2026-08-20 사용자 지적).
// 접점을 못 찾은 쪽은 보호 구간을 노견·측구까지로 둔다.
const keepMin = meetLeft ?? protectMin;
const keepMax = meetRight ?? protectMax;
const isProtected = (a: { offset_m: number }, b: { offset_m: number }): boolean =>
Math.max(a.offset_m, b.offset_m) > keepMin && Math.min(a.offset_m, b.offset_m) < keepMax;
for (let i = 1; i < line.length; i += 1) {
if (overlap[i - 1] && overlap[i] && !isProtected(line[i - 1], line[i])) continue;
const clipped = clipSegment(line[i - 1], line[i]);
if (!clipped) continue;
const seg = document.createElementNS(SVG_NS, "line");
seg.setAttribute("x1", String(x(clipped[0].offset_m)));
seg.setAttribute("y1", String(toDisplayY(clipped[0].elevation_m)));
seg.setAttribute("x2", String(x(clipped[1].offset_m)));
seg.setAttribute("y2", String(toDisplayY(clipped[1].elevation_m)));
seg.setAttribute("class", "b06-chart__design-cross");
svg.append(seg);
}
// 배수관 측점 성토 사면 — 백엔드 설계선(1:1.2 고정) 대신 **배수관 레이아웃이 준 직선**
// 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일
// 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도).
for (const segment of [trim?.minSlope, trim?.maxSlope]) {
if (!segment || segment.points.length < 2) continue;
// 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. 물매가 1:1.2 고정이라
// 벽이 밖으로 나간 만큼 노면이 연장된다(2026-08-21 사용자 확정).
for (let i = 1; i < segment.points.length; i += 1) {
const a = segment.points[i - 1];
const b = segment.points[i];
const seg = document.createElementNS(SVG_NS, "line");
seg.setAttribute("x1", String(x(a.offset)));
seg.setAttribute("y1", String(toDisplayY(a.elevation)));
seg.setAttribute("x2", String(x(b.offset)));
seg.setAttribute("y2", String(toDisplayY(b.elevation)));
seg.setAttribute("class", "b06-chart__design-cross");
svg.append(seg);
}
}
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
// 구분이 안 되는 경계를 표시한다.
const edges = design.carriageway_edges;
if (edges) {
const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => {
const cx = x(offsetM);
const cy = toDisplayY(elevationM);
const tick = document.createElementNS(SVG_NS, "line");
tick.setAttribute("x1", String(cx));
tick.setAttribute("y1", String(cy - half));
tick.setAttribute("x2", String(cx));
tick.setAttribute("y2", String(cy + half));
tick.setAttribute("class", className);
svg.append(tick);
};
for (const edge of [edges.left, edges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6);
}
// 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어
// **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로
// 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다.
const roadEdges = design.road_edges;
if (roadEdges) {
for (const edge of [roadEdges.left, roadEdges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4);
}
}
// 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다.
// 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0);
const standardWidth = design.carriageway_standard_width_m;
const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null;
const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null;
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute(
"x",
String(labelAt?.x ?? (x(edges.left.offset_m) + x(edges.right.offset_m)) / 2),
);
label.setAttribute("y", String(labelAt?.y ?? toDisplayY(edges.left.elevation_m) - 6));
label.setAttribute("class", "b06-chart__carriageway-label");
const widthText =
widened > 0.001 && typeof standardWidth === "number"
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})`
: `노폭 ${design.carriageway_width_m.toFixed(2)}m`;
// 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다.
label.textContent =
shoulderLeft != null && shoulderRight != null
? `${widthText} · 노견 ${
Math.abs(shoulderLeft - shoulderRight) < 0.005
? `${shoulderLeft.toFixed(2)}m`
: `좌 ${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m`
}`
: widthText;
(labelAt?.layer ?? svg).append(label);
}
}
+2 -30
View File
@@ -16,7 +16,6 @@
* 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
FILL_SLOPE_RATIO_MIN,
@@ -241,8 +240,7 @@ export function computeRevetmentLayout(
roadElevation: edge.elevation_m,
});
// 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07).
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고,
// 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0 이어서 까닭을 알 길이 없었다.
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났음.
if (requestedX > 0.005 && floor > requestedX + 0.005) {
tier1.shiftFloorM = Math.round(floor * 100) / 100;
}
@@ -324,27 +322,6 @@ export function computeRevetmentLayout(
};
}
/** 자리가 없어 못 세운 단이 있으면 **한 번만** 알린다 — 카드마다 띄우면 화면이 덮인다. */
const shortfallStations = new Set<number>();
let shortfallTimer = 0;
let shortfallLast = "";
function noticeShortfall(layout: RevetmentLayout): void {
if (layout.walls.length >= layout.requestedTiers) return;
shortfallStations.add(Math.round(layout.chainageM * 100));
window.clearTimeout(shortfallTimer);
shortfallTimer = window.setTimeout(() => {
const message =
`기슭막이 ${layout.requestedTiers}단 중 ${layout.walls.length}단만 세울 수 있습니다` +
`(측점 ${shortfallStations.size}곳) — 아래 자리가 원지반에 묻힙니다. ` +
"기준 벽을 사면 위로 올린 뒤(▲) 단을 늘리세요.";
shortfallStations.clear();
if (message === shortfallLast) return;
shortfallLast = message;
showToast(message, "warning");
}, 400);
}
/** 횡단 카드에 독립 기슭막이(1단+다단)를 그린다 — 배관 벽과 같은 도형. 그렸으면 setter. */
export function appendRevetmentOverlay(
svg: SVGElement,
@@ -354,7 +331,6 @@ export function appendRevetmentOverlay(
onSelect?: () => void,
): ((active: boolean) => void) | null {
if (!layout || !layout.walls.length) return null;
noticeShortfall(layout);
// 성토부선(벽 사이·벽 아래) 먼저 — 벽 밑에 깔린다.
for (const segment of layout.fillSegments) {
if (segment.kind === "cut") continue;
@@ -383,11 +359,7 @@ export function appendRevetmentOverlay(
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도`
: ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m`) +
(wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "") +
// 「좌우로 옮겨도 안 움직인다」의 까닭 두 가지 — 배관 벽 툴팁과 같은 문구다
// (2026-09-07). 독립 벽 툴팁은 따로 만들어져 있어 여태 어느 쪽도 안 떴다.
(wall.shiftBlockedM
? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)`
: "") +
// 「좌우로 옮겨도 안 움직인다」의 까닭 — 배관 벽 툴팁과 같은 문구(2026-09-07).
(wall.shiftFloorM
? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` +
` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임`
+6 -29
View File
@@ -21,9 +21,9 @@ export interface WallLeanInput {
form?: string | null;
/** 직고(m) — 순수 높이. */
height_m: number;
/** 측점 단면유형 `design.section_mode`. */
/** 측점 단면유형 `design.section_mode` — 기울기 판정에 안 씀(91-3 · 칸 · 성토 기본). */
section_mode?: string | null;
/** 설치 측 — 「자동(성토 쪽)」·「좌」·「우」(없으면 자동). */
/** 설치 측 — 기울기 판정에 안 씀(91-3). */
side?: string | null;
/** 성토/절토 칸 — 고르면 이김. */
face_role?: string | null;
@@ -38,32 +38,10 @@ const FILL = "성토";
const CUT = "절토";
/** 형태 → 찰(true)/메(false) — 파이썬 `REVETMENT_STONE_FORMS`. */
const STONE_FORMS: Record<string, boolean> = { "돌쌓기(찰)": true, "돌쌓기(메)": false };
/** 단면유형 → (좌측 역할, 우측 역할). 좌 = +offset. */
const MODE_ROLES: Record<string, [string, string]> = {
left_cut: [CUT, FILL],
right_cut: [FILL, CUT],
both_cut: [CUT, CUT],
both_fill: [FILL, FILL],
};
const LEFT = new Set(["좌", "left", "L"]);
const RIGHT = new Set(["우", "right", "R"]);
const AUTO = new Set(["자동(성토 쪽)", "자동", "auto", ""]);
/** 성토/절토 — 칸이 이기고, 비면 단면유형 + 설치 측. 못 가르면 `null`(성토로 눅이지 않음). */
export function faceRoleOf(
sectionMode: string | null | undefined,
side: string | null | undefined,
faceRole?: string | null,
): string | null {
/** 성토/절토 — 칸이 이기고, 비면 성토(흙막이 · 사면안정 흙막이 · 옹벽 모두 · 원지반 자동판정 안 씀 · 91-3). */
export function faceRoleOf(faceRole?: string | null): string {
const picked = String(faceRole ?? "").trim();
if (picked === FILL || picked === CUT) return picked;
const roles = MODE_ROLES[String(sectionMode ?? "").trim()];
if (!roles) return null;
const where = String(side ?? "").trim();
if (LEFT.has(where)) return roles[0];
if (RIGHT.has(where)) return roles[1];
if (AUTO.has(where)) return roles[0] === CUT && roles[1] === CUT ? null : FILL;
return null;
return picked === CUT ? CUT : FILL;
}
/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */
@@ -72,8 +50,7 @@ export function wallLeanRatio(table: MasonrySlopeTable, input: WallLeanInput): n
if (wet === undefined) return LEGACY_LEAN_RATIO;
const given = input.face_slope_ratio;
if (typeof given === "number" && Number.isFinite(given) && given > 0) return given;
const face = faceRoleOf(input.section_mode, input.side, input.face_role);
if (!face) return LEGACY_LEAN_RATIO;
const face = faceRoleOf(input.face_role);
const row = table.table?.[wet ? "찰쌓기" : "메쌓기"]?.[face];
if (!row?.length) return LEGACY_LEAN_RATIO;
const steps = table.steps_m ?? [];
@@ -61,16 +61,19 @@ def structure_face_role(section_mode: str | None, side: str | None) -> tuple[str
return None, f"설치 측을 알 수 없음({side!r}) — 성절토를 가를 근거 없음"
#: 성토/절토를 **사용자가 고르는 칸** — 비우면 위 판정(계산이지 기본값이 아님 · 2026-09-14 브레인 판정 ㉲).
#: 성토/절토를 **사용자가 고르는 칸** — 비우면 성토(91-3).
FACE_ROLE_KEY = "face_role"
def structure_face_role_of(
section_mode: str | None, options: dict | None
) -> tuple[str | None, str]:
"""칸 `face_role`(성토·절토)을 골랐으면 그 값, 비었으면 단면유형 + 설치 측 판정."""
"""벽 기울기용 — 칸 `face_role`(성토·절토)을 골랐으면 그 값, 비었으면 성토.
단면유형(원지반 자동판정)은 안 봄(91-3 · TS 짝 `common_util_masonry_slope.faceRoleOf`).
"""
values = options or {}
picked = str(values.get(FACE_ROLE_KEY) or "").strip()
if picked in (FILL, CUT):
return picked, f"사용자 지정 {picked}면(성토/절토 칸)"
return structure_face_role(section_mode, values.get("side"))
return FILL, "성토/절토 칸 비움 → 성토면(기본)"
@@ -97,4 +97,34 @@ out.fordHandBedDown = fordFloor((s) => {
hand(s);
shiftGround(s, -2, -2, 2);
});
// ② 91-3 다단 자동 자리 — 관 180 유출 흙막이 + 2단 · 원지반을 옮겨도 단 자리 같음 · 사면 1.0 m
const { computeCulvertLayout } = loadTs(B06("B06_Section_UI_Cross_Culvert_Geom.ts"));
function tiers(dz) {
const s = clone(at(180));
shiftGround(s, dz);
s.culvert = {
type: "pipe",
chainage_m: 180,
pipe_kind: "흄관",
diameter_m: 1.0,
min_cover_m: 0.5,
inlet: { role: "inlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" },
outlet: { role: "outlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" },
};
s.design.inlet_structure = "revet";
const tier = { x: null, d: null, h: 2, m: null };
const layout = computeCulvertLayout(s, s.samples, { extras: [tier, tier] }, "revet");
return {
walls: layout.extraWalls.map((w) => [r3(w.bottomBack.offset), r3(w.bottomBack.elevation)]),
slopes: layout.outletFill.segments.slice(0, 2).map((g) => r3(g.lengthM)),
};
}
out.tiers = [tiers(0), tiers(0.5), tiers(-0.5)];
// ⑤ 91-3 전면 기울기 — 단면유형(원지반 자동판정)과 무관 · 성토/절토 칸만
const { leanOf } = loadTs(B06("B06_Section_UI_Cross_Lean.ts"));
out.lean = ["left_cut", "right_cut", "both_fill", "both_cut"].map((mode) => [
leanOf({ form: "돌쌓기(찰)", height_m: 1.5, section_mode: mode, side: "좌" }),
leanOf({ form: "돌쌓기(찰)", height_m: 1.5, section_mode: mode, side: "좌", face_role: "절토" }),
]);
process.stdout.write(JSON.stringify(out));
+39
View File
@@ -44,3 +44,42 @@ def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict)
def test_세월교_박스_칸은_측벽_높이_줄을_받음() -> None:
dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
assert "sideSlots?.wingOutlet) return WALL_HEIGHT_ROW" in dock
def test_다단_자동_자리는_원지반과_무관_사면_1m(fit: dict) -> None:
base, up, down = fit["tiers"]
assert base["walls"] == up["walls"] == down["walls"]
assert base["slopes"] == [1.0, 1.0]
def test_전면_기울기는_단면유형과_무관_칸만(fit: dict) -> None:
# 단면유형 넷 모두 같은 값 · 비면 성토 표값(1.5 m 찰 = 0.25) · 절토 칸 = 0.2
assert all(pair == [0.25, 0.2] for pair in fit["lean"])
def test_B08_성토절토_판정도_칸_아니면_성토() -> None:
from common_util.common_util_structure_face_role import structure_face_role_of
for mode in ("left_cut", "right_cut", "both_cut", "both_fill"):
for side in ("좌", "우", "자동(성토 쪽)"):
assert structure_face_role_of(mode, {"side": side})[0] == "성토"
assert structure_face_role_of("both_fill", {"face_role": "절토"})[0] == "절토"
def test_설계선_끝은_화면_픽셀이_아니라_수치로() -> None:
line = (ROOT / "B06_Section/B06_Section_UI_Cross_Design_Line.ts").read_text(encoding="utf-8")
assert "OVERLAP_PX" not in line
assert line.count("Math.abs(point.elevation_m - g) <= OVERLAP_M") == 2
def test_지형에_막힘_잔재_없음() -> None:
for name in (
"B06_Section_UI_Cross_Culvert.ts",
"B06_Section_UI_Cross_Revetment.ts",
"B06_Section_UI_Cross_Culvert_Wire.ts",
"B06_Section_UI_Cross_Culvert_Types.ts",
"B06_Section_UI_Cross_Culvert_Extra.ts",
):
source = (ROOT / "B06_Section" / name).read_text(encoding="utf-8")
assert "shiftBlockedM" not in source and "지형에 막힘" not in source, name
assert "noticeExtraLimit" not in source and "noticeShortfall" not in source, name
-4
View File
@@ -290,10 +290,6 @@ export const ui_locales_b2 = {
"Distribute tiers evenly along the slope",
],
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+",
],
B06_Cross_Struct_Auto: ["자동(규칙)", "Auto (by rule)"],
B06_Cross_Struct_Revet: ["기슭막이+배관", "Revetment + pipe"],
B06_Cross_Struct_I: ["집수정 I형", "Catch basin — I"],