diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index e8e02a3c4..245e18dd4 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -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})를` + ` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임` diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index e4bad48ea..05082dfcf 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -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: diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index f516c140e..720b9452e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -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; // 관통 금지: 상단 ≤ 윗단 하단 → 그만큼은 반드시 내려간다. diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index d45f2a726..9888d2c48 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -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, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 1c8af8d03..322748453 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 2c39d8d32..f22cb5c36 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -151,31 +151,9 @@ export interface CulvertLink { * 옆 측점에 서야 한다). 고정 기본 10m(5/5)는 소유 벽 제원조차 없을 때의 마지막 값이다. * 손으로 한 번 잡은 단은 그 값으로 고정된다 — 계곡부·능선부에서 단마다 연장이 다르다. */ -/** 「단이 못 섰다」 안내를 이미 낸 자리 — 다시 그릴 때마다 뜨지 않게 한 번만 낸다. - * 키에 세워진 단 수를 넣어, 지형·조작이 바뀌어 결과가 달라지면 다시 알린다. */ -const extraLimitNotified = new Set(); - /** 벽마다 직전에 적용된 자리 — 자리 조작이 한계에 막혀 벽이 그대로일 때만 알리려고 둠. */ const lastApplied = new Map(); -/** 요청 단 수보다 적게 섰음을 알린다 — 조정창을 열지 않은 경로(좌측 폼 등)에서도 뜬다. - * 종전에는 `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], diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 47e973173..19e1e8dbc 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -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); - } -} - /** * 암 경계선(지면선 복사 + 상하 오프셋, 점선)을 겹쳐 그린다. * 계획선(설계선)이 아니라 **지반선(지면선)**을 복사해 이동하는 것이 규칙이다. diff --git a/B06_Section/B06_Section_UI_Cross_Design_Line.ts b/B06_Section/B06_Section_UI_Cross_Design_Line.ts new file mode 100644 index 000000000..cc9dbfae3 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Design_Line.ts @@ -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); + } +} diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index f73a30df1..0c802f065 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -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(); -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})를` + ` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임` diff --git a/common_util/common_util_masonry_slope.ts b/common_util/common_util_masonry_slope.ts index 7b9f1a98e..f9c500a2c 100644 --- a/common_util/common_util_masonry_slope.ts +++ b/common_util/common_util_masonry_slope.ts @@ -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 = { "돌쌓기(찰)": true, "돌쌓기(메)": false }; -/** 단면유형 → (좌측 역할, 우측 역할). 좌 = +offset. */ -const MODE_ROLES: Record = { - 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 ?? []; diff --git a/common_util/common_util_structure_face_role.py b/common_util/common_util_structure_face_role.py index af614785d..4dfe00c84 100644 --- a/common_util/common_util_structure_face_role.py +++ b/common_util/common_util_structure_face_role.py @@ -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, "성토/절토 칸 비움 → 성토면(기본)" diff --git a/resources/tester/helper_91_ground_free.cjs b/resources/tester/helper_91_ground_free.cjs index fe598aba9..5aed020f1 100644 --- a/resources/tester/helper_91_ground_free.cjs +++ b/resources/tester/helper_91_ground_free.cjs @@ -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)); diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index c33e96f8e..7fc4cdf36 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -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 diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index ee0678e37..1431b0b66 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"],