From de78feabde5106e0d3c91861891723e2adca93e3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 22 Aug 2026 15:29:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EB=8B=A4=EB=8B=A8=20=EB=93=B1?= =?UTF-8?q?=EA=B0=84=EA=B2=A9=20=EB=B0=B0=EC=B9=98=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?+=20=EC=9C=A0=EC=9E=85=200=EB=8F=84=20=EC=A0=91=EC=86=8D?= =?UTF-8?q?=EC=84=A0=20+=20=EC=9C=A0=EC=B6=9C=20=EB=A7=A4=EB=AA=B0=20?= =?UTF-8?q?=EC=A0=88=ED=86=A0=EC=84=A0=C2=B7=EC=9D=B4=EB=8F=99=20=EA=B8=88?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 십자 우측 하단 ≡ 버튼 — 벽 사이 사면 구간을 같게 재배치(끝 성토부는 지형 결정값이라 제외). 다단 연쇄(아랫단 바닥 하강) 때문에 1회 분할이 아니라 구간 오차를 d로 되먹임하는 수렴 반복(≤8회, 허용 0.05m)으로 푼다. ② 유입 기슭막이 관 시작 접속선: 관 하단점이 원지반보다 위면 0도 성토선 (지반 교차에서 정지), 아래면 0도 1m 후 표준 절토 경사(1:n)로 지반까지. ③ 유출 마지막 구조물 시작점이 원지반에 묻히면 0도 절토선을 지반 교차까지 연장, 교차가 없는 자리는 이동 금지(배관 벽·추가 벽 공통 클램프). - 배관 벽 4축 배치를 placePipeWall(Solve)로 추출 — Geom 700줄 유지 Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 66 ++- .../B06_Section_UI_Cross_Culvert_Extra.ts | 424 ++++++++++++------ .../B06_Section_UI_Cross_Culvert_Geom.ts | 124 ++--- .../B06_Section_UI_Cross_Culvert_Solve.ts | 69 ++- .../B06_Section_UI_Cross_Culvert_Types.ts | 5 + .../B06_Section_UI_Cross_Culvert_Wire.ts | 6 + .../B06_Section_UI_Cross_Structure_Panel.ts | 5 +- B06_Section/B06_Section_UI_Cross_View.ts | 1 + .../B06_Section_UI_Page_Station_Controls.ts | 9 + B06_Section/B06_Section_UI_Style_Cross.css | 6 +- ui_template/ui_template_locale_b2.ts | 4 + 11 files changed, 483 insertions(+), 236 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 3eed160d..3bb329e0 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -74,31 +74,53 @@ export function appendCulvertOverlay( // ④ 유출구측 성토부선(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로). // 구간별 폴리라인: 관 하단 꼭짓점(또는 앞 추가 벽 전면 상단) → 다음 벽/원지반. + const planLine = (points: (typeof pipe.inlet)[], tooltip: string): void => { + if (points.length < 2) return; + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute( + "points", + points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), + ); + // 성토·절토 접속선은 공사 계획선 — 설계선과 같은 보라 실선. + line.setAttribute("class", "b06-chart__design-cross"); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = tooltip; + line.append(title); + layer.append(line); + }; const drawOutletFill = (): void => { for (const segment of layout.outletFill.segments) { - if (segment.points.length < 2) continue; - const line = document.createElementNS(SVG_NS, "polyline"); - line.setAttribute( - "points", - segment.points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), - ); - // 성토부선은 공사 계획선 — 설계선과 같은 보라 실선(집수정 절토·성토선과 동일). - line.setAttribute("class", "b06-chart__design-cross"); - const title = document.createElementNS(SVG_NS, "title"); - title.textContent = + if (segment.kind === "cut") { + // 매몰 구간 — 0도 절토선을 원지반 교차까지(2026-08-22 사용자 ③). + planLine( + segment.points, + `유출부 0도 절토선 — 벽이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`, + ); + continue; + } + planLine( + segment.points, `유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + - (segment.overLimit - ? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)" - : " (법정 5m 이내)") + - ` · 성토 물매 1:${layout.fillSlope.ratio.toFixed(2)}` + - (layout.fillSlope.roadWideningM > 0.01 - ? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m` - : "") + - (layout.fillSlope.structureRequired - ? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)" - : ""); - line.append(title); - layer.append(line); + (segment.overLimit + ? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)" + : " (법정 5m 이내)") + + ` · 성토 물매 1:${(segment.ratio ?? layout.fillSlope.ratio).toFixed(2)}` + + (layout.fillSlope.roadWideningM > 0.01 + ? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m` + : "") + + (layout.fillSlope.structureRequired + ? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)" + : ""), + ); + } + // 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②). + if (layout.inletFill) { + planLine( + layout.inletFill.points, + layout.inletFill.kind === "cut" + ? `유입부 접속 절토선 — 관 하단에서 0도 1m 후 표준 절토 경사로 원지반까지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)` + : `유입부 0도 성토선 — 관 하단에서 수평, 원지반 교차에서 정지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`, + ); } }; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 2a167c55..4daa9656 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -41,6 +41,8 @@ export interface OutletExtrasInput { limitOffset: number; /** 단별 사용자 조작값(좌우 x·상하 d·높이 h·재질 m) — 요청한 단 수만큼. */ adjusts: WallAdjust[]; + /** 1회성 등간격 배치(2026-08-22 사용자 ①) — 단별 d를 사면 구간이 같아지게 재계산. */ + equalize?: boolean; } export interface OutletExtrasResult { @@ -52,6 +54,75 @@ export interface OutletExtrasResult { addable: boolean; } +/** 수평선(0도)이 계류측으로 나가며 원지반 아래로 다시 떨어지는 첫 지점(절토 교차). + * 없으면 null — 그 자리는 매몰 상태로 둘 수 없다(2026-08-22 사용자 ③ 이동 금지). */ +export function horizontalCrossing( + from: OffsetPoint, + outward: number, + groundAt: (offset: number) => number, + limitOffset: number, +): number | null { + const span = (limitOffset - from.offset) * outward; + for (let t = 0.25; t <= span + 1e-9; t += 0.25) { + const x = from.offset + outward * t; + if (groundAt(x) <= from.elevation) return x; + } + return null; +} + +/** + * 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②). + * · 관 하단점이 원지반보다 **위**: 0도 성토선 — 지반과 만나는 곳에서 정지. + * · **아래**: 0도 직선 1m 후 표준 절토 경사(1:n)로 지반까지 올라가는 절토선. + */ +export function inletGroundConnector( + corner: OffsetPoint, + outward: number, + groundAt: (offset: number) => number, + cutSlopeRatio: number, + limitOffset: number, +): OutletFillSegment | null { + const ground = groundAt(corner.offset); + const span = (limitOffset - corner.offset) * outward; + if (corner.elevation > ground + 0.02) { + let cross = corner.offset + outward * Math.max(span, 0); + for (let t = 0.25; t <= span + 1e-9; t += 0.25) { + const x = corner.offset + outward * t; + if (groundAt(x) >= corner.elevation) { + cross = x; + break; + } + } + return { + points: [corner, { offset: cross, elevation: corner.elevation }], + lengthM: Math.abs(cross - corner.offset), + overLimit: false, + kind: "fill", + }; + } + if (corner.elevation < ground - 0.02) { + const bend: OffsetPoint = { + offset: corner.offset + outward * 1.0, + elevation: corner.elevation, + }; + let last = bend; + const cutSpan = (limitOffset - bend.offset) * outward; + for (let t = 0.25; t <= Math.max(cutSpan, 0) + 1e-9; t += 0.25) { + const x = bend.offset + outward * t; + const elevation = bend.elevation + t / Math.max(cutSlopeRatio, 0.1); + last = { offset: x, elevation }; + if (elevation >= groundAt(x)) break; + } + return { + points: [corner, bend, last], + lengthM: 1 + Math.hypot(last.offset - bend.offset, last.elevation - bend.elevation), + overLimit: false, + kind: "cut", + }; + } + return null; +} + /** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */ function jointRunOf(height: number): number { return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height; @@ -160,162 +231,217 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult const { outward, groundAt, limitOffset } = input; const thickness = REVET_THICKNESS_M; const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN; - const walls: WallLayout[] = []; - const segments: OutletFillSegment[] = []; - const appliedAdjusts: WallAdjust[] = []; - let src = input.start; - let prevBottom = input.startBottomElevation; + /** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */ + const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => { + const walls: WallLayout[] = []; + const segments: OutletFillSegment[] = []; + const appliedAdjusts: WallAdjust[] = []; + let src = input.start; + let prevBottom = input.startBottomElevation; - for (let i = 0; i < input.adjusts.length; i += 1) { - // 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료. - if (groundAt(src.offset) >= src.elevation - 0.01) break; - const trailing = trailingFill(src, outward, groundAt); - if (trailing.lengthM < 0.7) break; + for (let i = 0; i < input.adjusts.length; i += 1) { + // 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료. + if (groundAt(src.offset) >= src.elevation - 0.01) break; + const trailing = trailingFill(src, outward, groundAt); + if (trailing.lengthM < 0.7) break; - const adjust = input.adjusts[i]; - const material = adjust.m ?? "dry"; - const limit = materialLimit(material); - const height = Math.min( - Math.max(adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, EXTRA_WALL_MIN_HEIGHT_M), - limit, - ); - - /** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */ - const heightAt = (x: number): number => { - const run = (x - src.offset) * outward; - return ( - (src.elevation - groundAt(x) - (run - 0.25 * thickness) / FILL_SLOPE_RATIO_MIN) / - heightDenominator + const adjust = input.adjusts[i]; + const material = adjust.m ?? "dry"; + const limit = materialLimit(material); + const height = Math.min( + Math.max(adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, EXTRA_WALL_MIN_HEIGHT_M), + limit, ); - }; - // 자동 자리 = 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) - height; - if (gap >= 0) { - // 교차점 선형 보간 — 격자 대신 정확 자리(사면선·지반 동시 접점). - const back = previousShort + gap > 1e-9 ? (gap / (previousShort + gap)) * step : 0; - autoOffset = x - outward * back; - break; - } - previousShort = -gap; - if (heightAt(x) > bestHeight) { - bestHeight = heightAt(x); - bestOffset = x; - } - } - if (autoOffset === null) autoOffset = bestOffset; - if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만. - // 관통 금지: 상단 ≤ 윗단 하단 → 성토선 수평거리(경사부) ≥ 1.2×(시작−윗단하단). - const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(height); - const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0); - const minD = Math.ceil((minJointRun - autoJointRun) * 10) / 10; - /** 후보 자리의 바닥(전면 근입 심화 반영) — buildExtraWall과 같은 계산. */ - const bottomAt = (anchorX: number, baseElevation: number): number => { - const baseWidth = REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height; - const jointX = anchorX - outward * (baseWidth / 2 - REVET_THICKNESS_M / 2); - const topFrontX = jointX + outward * REVET_THICKNESS_M; - const topElevation2 = baseElevation + height; - const frontXAt = (e: number): number => - topFrontX + outward * REVET_LEAN_RATIO * (topElevation2 - e); - let bottom = baseElevation - REVET_EMBED_DEPTH_M; - if (baseElevation - groundAt(anchorX) <= 1e-6) { - for (let pass = 0; pass < 6; pass += 1) { - const toe = Math.min(groundAt(frontXAt(bottom)), baseElevation); - if (toe - REVET_EMBED_DEPTH_M >= bottom - 1e-6) break; - bottom = toe - REVET_EMBED_DEPTH_M; + + /** 자리 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) - height; + if (gap >= 0) { + // 교차점 선형 보간 — 격자 대신 정확 자리(사면선·지반 동시 접점). + const back = previousShort + gap > 1e-9 ? (gap / (previousShort + gap)) * step : 0; + autoOffset = x - outward * back; + break; + } + previousShort = -gap; + if (heightAt(x) > bestHeight) { + bestHeight = heightAt(x); + bestOffset = x; } } - return bottom; - }; - // 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이 - // 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이 - // 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리). - const placeable = (xShift: number, dShift: number): boolean => { - const topE = src.elevation - (autoJointRun + dShift) / FILL_SLOPE_RATIO_MIN; - const aX = autoOffset + outward * (xShift + dShift); - const jointX = aX - outward * jointRunOf(height); - if (Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >= topE - 0.01) - return false; - const baseE = topE - height; - const bottom = bottomAt(aX, baseE); - const startE = bottom + REVET_EMBED_DEPTH_M; - const topFrontX = jointX + outward * REVET_THICKNESS_M; - const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE); - return groundAt(startX) < startE + 0.05; - }; - let appliedD = Math.max(adjust.d, minD); - let appliedX = Math.max(adjust.x, 0); - for (let pass = 0; pass < 400 && !placeable(appliedX, appliedD); pass += 1) { - // 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우). - if (appliedD > minD + 1e-9) appliedD = Math.max(minD, appliedD - 0.05); - else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05); - else break; + if (autoOffset === null) autoOffset = bestOffset; + if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만. + // 관통 금지: 상단 ≤ 윗단 하단 → 성토선 수평거리(경사부) ≥ 1.2×(시작−윗단하단). + const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(height); + const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0); + // 등간격 배치: 1차는 남은 구간(끝 포함) 균등 분할 근사, 이후 라운드는 보정 d. + const requestedD = + plan === "user" + ? adjust.d + : plan === "greedy" + ? (trailing.lengthM / (input.adjusts.length - i + 1)) * + (FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN)) - + autoJointRun + : (plan[i] ?? 0); + const requestedX = plan === "user" ? adjust.x : 0; + const minD = Math.ceil((minJointRun - autoJointRun) * 10) / 10; + /** 후보 자리의 바닥(전면 근입 심화 반영) — buildExtraWall과 같은 계산. */ + const bottomAt = (anchorX: number, baseElevation: number): number => { + const baseWidth = REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height; + const jointX = anchorX - outward * (baseWidth / 2 - REVET_THICKNESS_M / 2); + const topFrontX = jointX + outward * REVET_THICKNESS_M; + const topElevation2 = baseElevation + height; + const frontXAt = (e: number): number => + topFrontX + outward * REVET_LEAN_RATIO * (topElevation2 - e); + let bottom = baseElevation - REVET_EMBED_DEPTH_M; + if (baseElevation - groundAt(anchorX) <= 1e-6) { + for (let pass = 0; pass < 6; pass += 1) { + const toe = Math.min(groundAt(frontXAt(bottom)), baseElevation); + if (toe - REVET_EMBED_DEPTH_M >= bottom - 1e-6) break; + bottom = toe - REVET_EMBED_DEPTH_M; + } + } + return bottom; + }; + // 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이 + // 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이 + // 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리). + const placeable = (xShift: number, dShift: number): boolean => { + const topE = src.elevation - (autoJointRun + dShift) / FILL_SLOPE_RATIO_MIN; + const aX = autoOffset + outward * (xShift + dShift); + const jointX = aX - outward * jointRunOf(height); + if ( + Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >= + topE - 0.01 + ) + return false; + const baseE = topE - height; + const bottom = bottomAt(aX, baseE); + const startE = bottom + REVET_EMBED_DEPTH_M; + const topFrontX = jointX + outward * REVET_THICKNESS_M; + const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE); + if (groundAt(startX) < startE + 0.05) return true; + // 매몰 — 0도 절토선이 원지반과 다시 만나면 허용(2026-08-22 ③), 아니면 이동 금지. + return ( + horizontalCrossing( + { offset: startX, elevation: startE }, + outward, + groundAt, + limitOffset, + ) !== null + ); + }; + let appliedD = Math.max(requestedD, minD); + let appliedX = Math.max(requestedX, 0); + for (let pass = 0; pass < 400 && !placeable(appliedX, appliedD); pass += 1) { + // 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우). + if (appliedD > minD + 1e-9) appliedD = Math.max(minD, appliedD - 0.05); + else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05); + else break; + } + if (!placeable(appliedX, appliedD)) break; // 자동 자리조차 매몰 — 이 단은 불가. + appliedD = Math.round(appliedD * 10) / 10; + appliedX = Math.round(appliedX * 10) / 10; + appliedAdjusts.push({ + x: appliedX, + d: appliedD, + h: adjust.h != null ? height : null, + m: adjust.m, + }); + + const slopeRun = autoJointRun + appliedD; + const topElevation = src.elevation - slopeRun / FILL_SLOPE_RATIO_MIN; + const anchorX = autoOffset + outward * (appliedX + appliedD); + const base = topElevation - height; // 근입 0.5 위 기준선 + const wall = buildExtraWall( + i, + { offset: anchorX, elevation: base }, + outward, + height, + material, + Math.max(0, base - groundAt(anchorX)), + groundAt, + ); + walls.push(wall); + // 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부. + const shelf: OffsetPoint | null = + appliedX > 1e-9 + ? { offset: src.offset + outward * appliedX, elevation: src.elevation } + : null; + const slopeFrom = shelf ?? src; + const run = Math.abs(wall.topJoint.offset - slopeFrom.offset); + const rise = slopeFrom.elevation - wall.topJoint.elevation; + segments.push({ + points: shelf ? [src, shelf, wall.topJoint] : [src, wall.topJoint], + lengthM: Math.hypot(run, rise), + ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN, + overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6, + }); + // 다음 단 시작점 = 이 벽 **실제 바닥**(근입 심화 반영) +0.5m 수평선과 전면 + // 경사선(1:0.3)의 교차점(2026-08-22 사용자 — 관 끝점 규칙과 동일 기준). + const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; + src = { + offset: + wall.points[2].offset + + outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation), + elevation: startElevation, + }; + prevBottom = wall.bottomBack.elevation; } - if (!placeable(appliedX, appliedD)) break; // 자동 자리조차 매몰 — 이 단은 불가. - appliedD = Math.round(appliedD * 10) / 10; - appliedX = Math.round(appliedX * 10) / 10; - appliedAdjusts.push({ - x: appliedX, - d: appliedD, - h: adjust.h != null ? height : null, - m: adjust.m, - }); - const slopeRun = autoJointRun + appliedD; - const topElevation = src.elevation - slopeRun / FILL_SLOPE_RATIO_MIN; - const anchorX = autoOffset + outward * (appliedX + appliedD); - const base = topElevation - height; // 근입 0.5 위 기준선 - const wall = buildExtraWall( - i, - { offset: anchorX, elevation: base }, - outward, - height, - material, - Math.max(0, base - groundAt(anchorX)), - groundAt, + // 끝 구간(2026-08-22 사용자 ③): 시작점이 지반 위면 성토부선, 지반 아래(매몰)면 + // **0도 절토선**을 원지반 교차까지 연장한다(교차가 없는 자리는 placeable이 막는다). + let addable = false; + if (groundAt(src.offset) < src.elevation - 0.01) { + const tail = trailingFill(src, outward, groundAt); + segments.push(tail); + addable = tail.overLimit; + } else { + const cross = horizontalCrossing(src, outward, groundAt, limitOffset); + if (cross !== null) { + segments.push({ + points: [src, { offset: cross, elevation: src.elevation }], + lengthM: Math.abs(cross - src.offset), + overLimit: false, + kind: "cut", + }); + } + } + return { walls, segments, appliedAdjusts, addable }; + }; + + if (!input.equalize) return cascadeOnce("user"); + // 등간격(2026-08-22 ①) = **벽 사이 사면 구간**을 같게(끝 성토부는 지형이 정하는 + // 값이라 제외). 다단은 아래 단 바닥이 깊어질수록 뒷구간이 줄어드는 연쇄라 1회 + // 분할로는 안 맞는다 — 현 자리에서 구간 오차를 d로 되먹임하며 수렴시킨다. + let result = cascadeOnce("user"); + const horizontalPerSlope = FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN); + for (let round = 0; round < 8; round += 1) { + const count = result.walls.length; + if (count < 2) break; // 한 단이면 등간격 개념이 없다 + const lengths = result.segments.slice(0, count).map((segment) => segment.lengthM); + const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length; + if (Math.max(...lengths.map((l) => Math.abs(l - mean))) < 0.05) break; + const dNext = result.appliedAdjusts.map( + (applied, i) => applied.d + (mean - (lengths[i] ?? mean)) * horizontalPerSlope, ); - walls.push(wall); - // 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부. - const shelf: OffsetPoint | null = - appliedX > 1e-9 - ? { offset: src.offset + outward * appliedX, elevation: src.elevation } - : null; - const slopeFrom = shelf ?? src; - const run = Math.abs(wall.topJoint.offset - slopeFrom.offset); - const rise = slopeFrom.elevation - wall.topJoint.elevation; - segments.push({ - points: shelf ? [src, shelf, wall.topJoint] : [src, wall.topJoint], - lengthM: Math.hypot(run, rise), - ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN, - overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6, - }); - // 다음 단 시작점 = 이 벽 **실제 바닥**(근입 심화 반영) +0.5m 수평선과 전면 - // 경사선(1:0.3)의 교차점(2026-08-22 사용자 — 관 끝점 규칙과 동일 기준). - const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; - src = { - offset: - wall.points[2].offset + - outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation), - elevation: startElevation, - }; - prevBottom = wall.bottomBack.elevation; + result = cascadeOnce(dNext); } - - // 끝 성토부 — 마지막 구조물의 시작점이 지반 위일 때만 그린다(묻히면 성토 불필요). - let addable = false; - if (groundAt(src.offset) < src.elevation - 0.01) { - const tail = trailingFill(src, outward, groundAt); - segments.push(tail); - addable = tail.overLimit; - } - return { walls, segments, appliedAdjusts, addable }; + return result; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 2582ddcd..7bd6f75a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -51,7 +51,7 @@ import { buildBasin, inletChoiceAvailability, } from "./B06_Section_UI_Cross_Culvert_Basin"; -import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { buildOutletExtras, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { clampToFace, @@ -63,6 +63,7 @@ import { designInterpolator, intersect, slopeToeOffset, + placePipeWall, slopeLengthAlong, STRAY_LIMIT_M, } from "./B06_Section_UI_Cross_Culvert_Solve"; @@ -78,6 +79,8 @@ export function computeCulvertLayout( revetShift?: { inlet?: WallAdjust; outlet?: WallAdjust; extras?: WallAdjust[] }, /** 유입측 구조물 형식 선택(드롭다운) — 없으면 auto(규칙). */ inletStructure?: InletStructureChoice, + /** 다단 등간격 배치 1회성 트리거(2026-08-22 사용자 ①). */ + equalizeExtras?: boolean, ): CulvertLayout | null { const culvert = section.culvert; if (!culvert) return null; @@ -91,7 +94,7 @@ export function computeCulvertLayout( const maxSample = Math.max(...sampleOffsets); if (!(maxSample > minSample)) return null; - // 좌표 규약: +offset = 좌측(화면 왼쪽). 상단측이 유입이다(미상이면 좌측 폴백). + // 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백). const uphill = section.uphill_side ?? "left"; const sideInfo = (side: "left" | "right") => ({ edge: side === "left" ? edges.left : edges.right, @@ -101,17 +104,14 @@ export function computeCulvertLayout( const inletInfo = sideInfo(uphill === "left" ? "left" : "right"); const outletInfo = sideInfo(uphill === "left" ? "right" : "left"); - // 유입 invert: 유입 노견 아래 원지반. 절토측이면 "노면 − 관경 − 토피"가 상한 - // (관 상단 + 토피가 노면 안에 들어가는 최고 자리 — B05 하향 차단식과 동일). + // 유입 invert: 노견 아래 원지반. 상한 = 노면 − 관경 − 토피(B05 하향 차단식과 동일). const diameter = culvert.diameter_m; const invertCap = (edge: { offset_m: number; elevation_m: number }): number => (designAt ? designAt(edge.offset_m) : edge.elevation_m) - diameter - MIN_PIPE_COVER_M; const inletInvert = Math.min(groundAt(inletInfo.edge.offset_m), invertCap(inletInfo.edge)); const inlet: OffsetPoint = { offset: inletInfo.edge.offset_m, elevation: inletInvert }; - // 유입측 집수정 판정(2026-08-22 사용자 재정의): **성토사면 길이 ≤ 3m**면 기슭막이 - // +배관을 두기 어려워 집수정이 기본이 된다(종전 절토/30% 막힘 판정 대체 — 절토측 - // 유입은 성토사면이 없으므로 자동 포함). 사용자 선택(기슭막이 유지)은 향후 추가. + // 유입측 집수정 판정(2026-08-22 재정의): 성토사면 ≤3m면 집수정 기본(절토 포함). const mode = section.design?.section_mode; const inletSideName: "left" | "right" = uphill === "left" ? "left" : "right"; const inletIsCut = @@ -203,30 +203,29 @@ export function computeCulvertLayout( Math.min(groundAt(offset), invertCap(inletInfo.edge)); // 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용. inletAutoOffset = inletOptions.revetAutoOffset; - // 4축 조작(2026-08-22 확정): 좌우 x = 노견 연장 **평행이동**(상단 표고·물매 - // 1:1.2 불변, 안쪽 x<0 금지 — 자동 자리가 이미 노견 최소), 상하 d = 성토선을 - // 타는 대각 이동(수평 성분, +아래). 위(−d) 한계 = 최소 성토고·토피 상한. - // 바닥이 지반에 못 닿으면 떠도 무관(별도 지지 구조물 예정). - const h = inletWallSpec.height; - const top0 = inletBaseAt(inletAutoOffset) + h; - const dLow = Math.max( - -FILL_SLOPE_RATIO_MIN * (inletInfo.edge.elevation_m - FILL_MIN_RISE_M - top0), - -FILL_SLOPE_RATIO_MIN * (invertCap(inletInfo.edge) - (top0 - h)), - ); - const appliedD = Math.max(adjInlet.d, Math.ceil(dLow * 10) / 10); - const appliedX = Math.max(adjInlet.x, 0); - appliedAdjust.inlet.x = appliedX; - appliedAdjust.inlet.d = appliedD; - const topElev = top0 - appliedD / FILL_SLOPE_RATIO_MIN; - const anchorX = inletAutoOffset + inletInfo.outward * (appliedX + appliedD); - const invert = topElev - h; + // 4축 배치는 공용 풀이(placePipeWall — Solve, 700줄 제한): 좌우 = 노견 연장 + // 평행이동, 상하 = 성토선 대각, 위 한계 = 최소 성토고·토피 상한. + const placed = placePipeWall({ + autoOffset: inletAutoOffset, + outward: inletInfo.outward, + height: inletWallSpec.height, + baseElevation0: inletBaseAt(inletAutoOffset), + edgeElevation: inletInfo.edge.elevation_m, + invertCap: invertCap(inletInfo.edge), + adjust: adjInlet, + groundAt, + limitOffset: inletInfo.limit, + requireCrossing: false, + }); + appliedAdjust.inlet.x = placed.x; + appliedAdjust.inlet.d = placed.d; wallVertical.inlet = { - height: h, - baseElevation: invert, - floatGapM: Math.max(0, invert - inletBaseAt(anchorX)), + height: inletWallSpec.height, + baseElevation: placed.invert, + floatGapM: Math.max(0, placed.invert - inletBaseAt(placed.anchorOffset)), }; - inlet.offset = anchorX; - inlet.elevation = invert; + inlet.offset = placed.anchorOffset; + inlet.elevation = placed.invert; } // 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2). @@ -256,9 +255,7 @@ export function computeCulvertLayout( const span = Math.abs(outletAnchor.offset - inlet.offset); let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0; - // 기슭막이 합성 단면(사용자 ①·②): 하부 = 배면 수직·전면 1:0.3 **사다리꼴**(관이 - // 지나는 관경 높이), 상부 = 전·배면이 나란히 기운 **평행사변형**. 상단 배면 꼭짓점이 - // 성토 사면선(설계선)과 만나는 접점이 되도록 높이를 정한다 — 사면선은 거기서 끊긴다. + // 기슭막이 합성 단면: 하부 사다리꼴(배면 수직·전면 1:0.3) + 상부 평행사변형 띠. const walls: WallLayout[] = []; let basin: BasinLayout | null = null; /** 집수정이 서면 관 유입단을 내공 안으로 옮긴다(접속 표현). */ @@ -401,13 +398,7 @@ export function computeCulvertLayout( wallVertical.inlet, inletWallSpec.material, ); - // 유출 벽 자리 — 유입과 같은 규칙(사면선이 벽 이음선 상단점을 지나는 자리, 물매는 - // 1:1.2~2.0 범위에서 역산). - // - // 벽 밑 = 그 자리의 **원지반**. 종전처럼 관 축(inlet → 사면 끝 직선) 위에 얹으면 - // 성토측 지반이 관 물매보다 가파른 구간에서 축이 지반 위로 떠올라 **벽이 뜬다** - // (2026-08-21 사용자 지적). 관 끝은 원지반 위라는 원칙대로 지반을 따르면 벽이 - // 지반에 앉고 관 물매가 그 자리에 맞춰진다. 역경사는 유입 invert로 클램프. + // 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21). const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation); // 유출 벽 자동 자리도 **노견 최소 지점**(2026-08-22 사용자 확정) — 못 찾으면 사면 끝. let outletWallAnchor = outletAnchor; @@ -423,26 +414,27 @@ export function computeCulvertLayout( : null; const outletAutoOffset = outletFeasible ?? outletAnchorOffset; if (designAt) { - // 4축 조작(유입과 동일). 위(−d) 한계 = 최소 성토고 + 역경사 방지(invert ≤ 유입). - const h = outletWallSpec.height; - const top0 = invertAt(outletAutoOffset) + h; - const dLow = Math.max( - -FILL_SLOPE_RATIO_MIN * (outletInfo.edge.elevation_m - FILL_MIN_RISE_M - top0), - -FILL_SLOPE_RATIO_MIN * (inlet.elevation - (top0 - h)), - ); - const appliedD = Math.max(adjOutlet.d, Math.ceil(dLow * 10) / 10); - const appliedX = Math.max(adjOutlet.x, 0); - appliedAdjust.outlet.x = appliedX; - appliedAdjust.outlet.d = appliedD; - const topElev = top0 - appliedD / FILL_SLOPE_RATIO_MIN; - const anchorX = outletAutoOffset + outletInfo.outward * (appliedX + appliedD); - const invert = topElev - h; + // 4축 배치(유입과 동일 풀이) + 유출 전용: 매몰-무교차 자리 금지(2026-08-22 ③). + const placed = placePipeWall({ + autoOffset: outletAutoOffset, + outward: outletInfo.outward, + height: outletWallSpec.height, + baseElevation0: invertAt(outletAutoOffset), + edgeElevation: outletInfo.edge.elevation_m, + invertCap: inlet.elevation, + adjust: adjOutlet, + groundAt, + limitOffset: outletInfo.limit, + requireCrossing: true, + }); + appliedAdjust.outlet.x = placed.x; + appliedAdjust.outlet.d = placed.d; wallVertical.outlet = { - height: h, - baseElevation: invert, - floatGapM: Math.max(0, invert - invertAt(anchorX)), + height: outletWallSpec.height, + baseElevation: placed.invert, + floatGapM: Math.max(0, placed.invert - invertAt(placed.anchorOffset)), }; - outletWallAnchor = { offset: anchorX, elevation: invert }; + outletWallAnchor = { offset: placed.anchorOffset, elevation: placed.invert }; } let outletWall = buildWall( culvert.outlet, @@ -453,11 +445,7 @@ export function computeCulvertLayout( wallVertical.outlet, outletWallSpec.material, ); - // ── 관 축 확정. 관 하단선은 **관 시작점(집수정 내공 벽 또는 유입 벽 자리)과 - // 유출 벽 하단선 중점(invert)을 잇는 직선**이다 — 벽이 어디로 가든 관은 그 벽 - // 바닥을 지난다(2026-08-22 사용자: 기슭막이는 관을 감싸는 구조물). 종전에는 정수 - // 맞춤이 벽을 옮긴 뒤에도 옛 축을 그대로 써서, 가파른 지반에서 관이 벽 바닥 - // 아래로 삐져나갔다(13+15.7 사용자 지적). + // ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다. const pipeStart = basinPipeEnd ?? inlet; /** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의 * 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */ @@ -620,6 +608,16 @@ export function computeCulvertLayout( if (basin) { applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt); } + // 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위 0도 성토선 / 아래 0도 1m+절토선. + const inletFill = !basin + ? inletGroundConnector( + pipeCorners.inlet.bottom, + inletInfo.outward, + groundAt, + section.design?.cut_slope_ratio ?? 1.0, + inletInfo.limit, + ) + : null; // 성토부선 + 추가 기슭막이(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로 // 남긴다). 끝 구간이 5m 이상이면 사용자가 추가 기슭막이(벽만)를 계단식으로 더 둔다. @@ -631,6 +629,7 @@ export function computeCulvertLayout( groundAt, limitOffset: outletInfo.limit, adjusts: (revetShift?.extras ?? []).map(adjustOf), + equalize: equalizeExtras === true, }) : { walls: [], segments: [], appliedAdjusts: [], addable: false }; @@ -677,6 +676,7 @@ export function computeCulvertLayout( structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M, }, outletFill: { segments: extras.segments, addable: extras.addable }, + inletFill, extraWalls: extras.walls, basin, revetShift: { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 6d324e14..375bb0f0 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -14,7 +14,8 @@ import { REVET_THICKNESS_M, REVET_TRAP_TOP_M, } from "./B06_Section_UI_Cross_Culvert_Const"; -import type { OffsetPoint, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { OffsetPoint, PipeEnd, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import { horizontalCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; /** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */ export function fillWallBaseWidth(height: number): number { @@ -344,3 +345,69 @@ export function slopeLengthAlong( } return length; } + +/** 배관 기슭막이 4축 배치 결과 — 좌우 x·상하 d(적용값)와 확정 자리. */ +export interface PipeWallPlacement { + x: number; + d: number; + topElevation: number; + anchorOffset: number; + invert: number; +} + +/** + * 배관 기슭막이 4축 배치(2026-08-22 확정 규칙 묶음) — 유입·유출 공용. + * · x = 노견 연장 평행이동(안쪽 금지), d = 성토선 대각(위 한계 = 최소 성토고· + * invert 상한(토피/역경사)). + * · requireCrossing(유출): 매몰인데 0도 절토선이 원지반과 다시 못 만나는 자리는 + * 금지 — 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우). + */ +export function placePipeWall(input: { + autoOffset: number; + outward: number; + height: number; + /** 자동 자리의 invert(= baseAt(autoOffset)). */ + baseElevation0: number; + edgeElevation: number; + /** invert 상한 — 유입: 노면−관경−토피, 유출: 유입 invert(역경사 방지). */ + invertCap: number; + adjust: WallAdjust; + groundAt: (offset: number) => number; + limitOffset: number; + requireCrossing: boolean; +}): PipeWallPlacement { + const top0 = input.baseElevation0 + input.height; + const dLow = Math.max( + -FILL_SLOPE_RATIO_MIN * (input.edgeElevation - FILL_MIN_RISE_M - top0), + -FILL_SLOPE_RATIO_MIN * (input.invertCap - (top0 - input.height)), + ); + let d = Math.max(input.adjust.d, Math.ceil(dLow * 10) / 10); + let x = Math.max(input.adjust.x, 0); + if (input.requireCrossing) { + const placeable = (anchorOffset: number, invertE: number): boolean => + invertE >= input.groundAt(anchorOffset) - 0.01 || + horizontalCrossing( + { offset: anchorOffset, elevation: invertE }, + input.outward, + input.groundAt, + input.limitOffset, + ) !== null; + for (let pass = 0; pass < 400; pass += 1) { + const anchorOffset = input.autoOffset + input.outward * (x + d); + if (placeable(anchorOffset, top0 - d / FILL_SLOPE_RATIO_MIN - input.height)) break; + if (Math.abs(d) > 1e-9) d = d > 0 ? Math.max(0, d - 0.05) : Math.min(0, d + 0.05); + else if (x > 1e-9) x = Math.max(0, x - 0.05); + else break; + } + d = Math.round(d * 10) / 10; + x = Math.round(x * 10) / 10; + } + const topElevation = top0 - d / FILL_SLOPE_RATIO_MIN; + return { + x, + d, + topElevation, + anchorOffset: input.autoOffset + input.outward * (x + d), + invert: topElevation - input.height, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 43d6883c..49f56902 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -126,6 +126,8 @@ export interface OutletFillSegment { lengthM: number; /** 구간 물매(1:n) — 벽 사이 구간은 높이 역산으로 정확히 1.2다. */ ratio?: number; + /** 선 성격(2026-08-22 사용자 ②③): fill = 성토선(기본), cut = 0도/접속 절토선. */ + kind?: "fill" | "cut"; /** 사면길이 5m 이상 — 기슭막이 의무 구간(별표2·성토_비탈면 §2). */ overLimit: boolean; } @@ -157,6 +159,9 @@ export interface CulvertLayout { }; /** 유출측 성토부선(보호공 대체 — 2026-08-22). 끝 구간이 5m 이상이면 추가 가능. */ outletFill: { segments: OutletFillSegment[]; addable: boolean }; + /** 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위면 0도 성토선(지반 정지), + * 아래면 0도 1m 후 표준 절토 경사선. 집수정·접속 불필요면 null. */ + inletFill: OutletFillSegment | null; /** 추가 기슭막이(배관 없는 벽 — 계단식). 조정창 키 = `extra{n}`. */ extraWalls: WallLayout[]; /** 유입 집수정 단면(있을 때만). */ diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 231f62ed..3d058e4c 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -36,6 +36,10 @@ export interface ExtraWallControl { countFor: (section: CrossSection) => number; /** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */ setCount: (chainageM: number, count: number) => void; + /** 다단 등간격 배치 요청(2026-08-22 ①) — 다음 계산 1회에 적용된다. */ + equalize: (chainageM: number) => void; + /** 등간격 요청을 소비(1회성) — 계산 직전에 Wire가 부른다. */ + consumeEqualize: (section: CrossSection) => boolean; /** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */ syncCount: (chainageM: number, built: number) => void; } @@ -62,6 +66,7 @@ export function computeCardCulvert( inletStructure?: InletStructureControl, extraWalls?: ExtraWallControl, ): CulvertLayout | null { + const equalizeExtras = extraWalls?.consumeEqualize(section) ?? false; const layout = computeCulvertLayout( section, sourceSamples, @@ -75,6 +80,7 @@ export function computeCardCulvert( } : undefined, inletStructure?.valueFor(section), + equalizeExtras, ); if (!layout) return null; if (extraWalls && extraWalls.countFor(section) > layout.extraWalls.length) { diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 6e2e8802..054a11ff 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -48,6 +48,8 @@ export interface StructurePanelDeps { extraState: () => { canAdd: boolean; count: number }; /** 다단 기슭막이 단 수 지정 — 지형 허용보다 크면 기하가 자르고 토스트로 알린다. */ setExtraCount: (count: number) => void; + /** 다단 등간격 배치(2026-08-22 ①) — 사면 구간이 같아지게 단들을 재배치. */ + equalizeExtras: () => void; } export interface StructurePanelHandle { @@ -121,7 +123,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan const buttons = moveRow.controls; const dpad = ( label: string, - slot: "up" | "down" | "left" | "right" | "reset", + slot: "up" | "down" | "left" | "right" | "reset" | "equal", title: string, onClick: () => void, ): HTMLButtonElement => { @@ -160,6 +162,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan L("B06_Cross_Revet_Down"), act((key) => deps.nudgeSlope(key, STEP_M)), ), + dpad("≡", "equal", L("B06_Cross_Extra_Equalize"), () => deps.equalizeExtras()), ); // 재질 행(2026-08-22 사용자 ④ — 높이 행 **위**, 폭 축소: 한계는 툴팁으로). diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 7b22c4a6..2b2efc35 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -646,6 +646,7 @@ export function createCrossSectionCard( } extraWalls?.setCount(section.chainage_m, count); }, + equalizeExtras: () => extraWalls?.equalize(section.chainage_m), }); showRevetControl = (visible) => panel.show(visible ? activeRevet : null); showRevetControl(activeRevet !== null); diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 768979db..5276dd7b 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -260,8 +260,17 @@ export function createStationControls(deps: StationControlDeps): StationControls } } + /** 등간격 배치 1회성 요청(2026-08-22 ①) — 다음 카드 계산에서 소비된다. */ + const pendingEqualize = new Set(); + const extraWallControl: ExtraWallControl = { countFor: (section) => extraCounts.get(section.chainage_m.toFixed(2)) ?? 0, + equalize: (chainageM) => { + if ((extraCounts.get(chainageM.toFixed(2)) ?? 0) <= 0) return; // 단이 없으면 무의미 + pendingEqualize.add(chainageM.toFixed(2)); + deps.refreshCard(chainageM); + }, + consumeEqualize: (section) => pendingEqualize.delete(section.chainage_m.toFixed(2)), setCount: (chainageM, requested) => { const key = chainageM.toFixed(2); const previous = extraCounts.get(key) ?? 0; diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index fa14a101..e64c8504 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -615,7 +615,7 @@ grid-template-areas: ". up ." "left reset right" - ". down ."; + ". down equal"; /* 십자 그룹은 패널 폭 기준 수평 가운데(2026-08-22 사용자) — 버튼 크기는 유지. */ justify-content: center; gap: 2px; @@ -641,6 +641,10 @@ grid-area: reset; } +.b06-structure-panel__btn--equal { + grid-area: equal; +} + .b06-structure-panel__btn.is-hidden { display: none; } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 43dc99fa..ea5a0655 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -281,6 +281,10 @@ export const ui_locales_b2 = { B06_Cross_Mat_Concrete: ["콘크리트", "Concrete"], B06_Cross_Extra_Count: ["추가 기슭막이(단)", "Extra revetments"], B06_Cross_Extra_Add: ["한 단 추가", "Add one tier"], + B06_Cross_Extra_Equalize: [ + "다단 등간격 배치 — 사면 구간을 같게", + "Distribute tiers evenly along the slope", + ], B06_Cross_Extra_Remove: ["한 단 삭제", "Remove one tier"], B06_Cross_Extra_Limit: [ "지형상 추가 기슭막이는 {n}단까지만 가능합니다 — 벽이 원지반에 0.5m 이상 묻히면 성토가 필요 없습니다",