From 30031c33c007015f024646a71f394c824e3b4281 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 22 Aug 2026 19:10:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20I=ED=98=95=20=EC=A7=91=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9D=B4=EB=8F=99=20=ED=95=9C=EA=B3=84=20=3D=20?= =?UTF-8?q?=EB=B0=B0=EA=B4=80=20=EA=B8=B8=EC=9D=B4=EA=B0=80=20=EB=B0=94?= =?UTF-8?q?=EB=80=8C=EA=B8=B0=20=EC=A7=81=EC=A0=84=EA=B9=8C=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I형은 관을 감싸는 구조라 집수정을 옮기면 관 시작점이 따라가 관 길이(m 규격)가 바뀐다. 길이가 달라지는 지점 **직전**이 최대 이동이다(2026-08-22 사용자 확정). - clampBasinMove(Basin): 요청값에서 0.1씩(상하 먼저, 다음 좌우) 되돌려 기준 길이와 같아지는 자리를 찾는다. ㄴ·ㄷ형은 대상 아님(관이 바닥에서 출발) - 적용값을 layout.basinAdjust로 돌려주고, Wire가 세션값을 실제 적용값으로 되돌린 뒤 토스트로 이유를 알린다(눌러도 안 움직이는데 숫자만 커지는 것 방지) - 700줄 유지: pipeAxisSolver(Solve)로 관 축·끝단 계산 이전, Geom 673줄 검증: 4+4.3 I형 좌우 2m 요청 → 0.7m 적용·관 길이 8m 유지, 13+15.7은 0.4m 적용·14m 유지. 상하 요청도 같은 한계. ㄴ형은 클램프 없이 2m 그대로 적용. 화면에서 ◀ 12회 연속 → 0.7m에서 멈추고 관 L=8.0m 불변, 토스트 안내. tsc·prettier 통과 · pytest 148 passed, 7 skipped · 700줄 초과 0건. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_Section_UI_Cross_Culvert_Basin.ts | 24 ++++++ .../B06_Section_UI_Cross_Culvert_Geom.ts | 84 +++++++------------ .../B06_Section_UI_Cross_Culvert_Solve.ts | 68 ++++++++++++++- .../B06_Section_UI_Cross_Culvert_Types.ts | 2 + .../B06_Section_UI_Cross_Culvert_Wire.ts | 13 +++ ui_template/ui_template_locale_b2.ts | 4 + 6 files changed, 141 insertions(+), 54 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index 7c5b3e6e..fa3dfb11 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -386,3 +386,27 @@ export function attachBasin(input: BasinBuildInput): BasinAttachResult { }); return { ...built, approach: points ? { points } : null }; } + +/** + * I형 집수정 **이동 한계**(2026-08-22 사용자 확정) — I형은 관을 감싸는 구조라 옮기면 + * 관 시작점이 따라가 **관 길이(m 규격)가 바뀐다**. 길이가 바뀌는 지점 직전이 최대다. + * 요청값에서 0.1씩(상하 먼저, 다음 좌우) 되돌려 기준 길이와 같아지는 자리를 찾는다. + * `probe`는 같은 단면을 다시 계산하는 함수(본체가 재귀로 물려 준다). + */ +export function clampBasinMove( + adjust: BasinAdjust, + probe: (candidate: BasinAdjust) => { shape: BasinShape | null; lengthM: number } | null, +): BasinAdjust { + if (adjust.lateralM <= 1e-9 && adjust.slopeM <= 1e-9) return adjust; + const base = probe({ ...adjust, lateralM: 0, slopeM: 0 }); + if (base?.shape !== "I") return adjust; + const round1 = (value: number): number => Math.round(value * 10) / 10; + let lateralM = adjust.lateralM; + let slopeM = adjust.slopeM; + for (let pass = 0; pass < 400 && (lateralM > 1e-9 || slopeM > 1e-9); pass += 1) { + if (probe({ ...adjust, lateralM, slopeM })?.lengthM === base.lengthM) break; + if (slopeM > 1e-9) slopeM = Math.max(0, round1(slopeM - 0.1)); + else lateralM = Math.max(0, round1(lateralM - 0.1)); + } + return { ...adjust, lateralM, slopeM }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 10bb80e0..ef207639 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -1,11 +1,9 @@ /* ============================================================================= * B06_Section_UI_Cross_Culvert_Geom.ts - * 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리. - * 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 배치 규칙은 실무 - * 횡단도 기준(2026-08-20 사용자 제공, 울진 계열): 기슭막이 평행사변형(전면 1:0.3), - * 배관은 두 벽 사이 직선·끝단면은 구조물 변과 평행, 유출측은 관 하단 꼭짓점부터 - * 성토부선(보호공 삭제 — 2026-08-22), 성토 경사선은 벽 접점에서 끊는다(designTrim). - * 유입 = 상단측(uphill_side). 집수정 구성은 `_Cross_Culvert_Basin.ts` 참조. + * 배수관 세트(배관·기슭막이·성토부) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리. + * 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 기슭막이는 전면 + * 1:0.3 평행사변형, 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다 + * (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`. * ========================================================================== */ import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; @@ -36,7 +34,6 @@ import type { EndFace, InletStructureChoice, OffsetPoint, - PipeEnd, WallAdjust, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; @@ -49,6 +46,7 @@ export * from "./B06_Section_UI_Cross_Culvert_Types"; import { applyBasinPipeFill, attachBasin, + clampBasinMove, BASIN_INNER_WIDTH_M, inletChoiceAvailability, resolveBasinChoice, @@ -56,19 +54,17 @@ import { import { buildExtrasAt, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { - clampToFace, cutLength, minShoulderWallOffset, outletSlopeFactory, fillSlopeOf, groundInterpolator, designInterpolator, - intersect, slopeToeOffset, + pipeAxisSolver, placeInletWall, placePipeWall, slopeLengthAlong, - STRAY_LIMIT_M, } from "./B06_Section_UI_Cross_Culvert_Solve"; /** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */ @@ -85,6 +81,8 @@ export function computeCulvertLayout( inletStructure?: InletStructureChoice, basinAdjustment?: BasinAdjust, equalizeExtras?: boolean, + /** 내부용 — 집수정 이동 한계 탐색 재귀에서 다시 클램프하지 않게 막는다. */ + skipBasinClamp?: boolean, ): CulvertLayout | null { const culvert = section.culvert; if (!culvert) return null; @@ -97,7 +95,21 @@ export function computeCulvertLayout( const minSample = Math.min(...sampleOffsets); const maxSample = Math.max(...sampleOffsets); if (!(maxSample > minSample)) return null; - const basinAdjust = basinAdjustment ?? DEFAULT_BASIN_ADJUST; + // I형 집수정 이동 한계 — 관 길이가 바뀌는 지점 직전까지(판정은 Basin이 맡는다). + const probeBasin = (candidate: BasinAdjust) => { + const p = computeCulvertLayout( + section, + groundSamples, + revetShift, + inletStructure, + candidate, + false, + true, + ); + return p ? { shape: p.basin?.shape ?? null, lengthM: p.pipe.lengthM } : null; + }; + const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST; + const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin); // 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백). const uphill = section.uphill_side ?? "left"; @@ -196,9 +208,8 @@ export function computeCulvertLayout( Math.min(groundAt(offset), invertCap(inletInfo.edge)); // 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용. inletAutoOffset = inletOptions.revetAutoOffset; - // 4축 배치는 공용 풀이(placePipeWall — Solve): 좌우 = 노견 연장 - // 평행이동, 상하 = 성토선 대각, 위 한계 = 최소 성토고·토피 상한. - // 유입을 내리면 유출도 따라 내려간다 — 유출이 못 받으면 유입도 못 내려간다(가드). + // 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라 + // 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard). const placed = placeInletWall({ autoOffset: inletAutoOffset, outward: inletInfo.outward, @@ -473,43 +484,9 @@ export function computeCulvertLayout( // ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질 // 때마다 다시 유도한다(2026-08-20 사용자 ③). - let axis: OffsetPoint = { offset: 1, elevation: 0 }; - let normal: OffsetPoint = { offset: 0, elevation: 1 }; - let topAnchor: OffsetPoint = pipeStart; - const deriveAxis = (): void => { - const runP = outlet.offset - pipeStart.offset; - const riseP = outlet.elevation - pipeStart.elevation; - const axisLength = Math.hypot(runP, riseP) || 1; - axis = { offset: runP / axisLength, elevation: riseP / axisLength }; - normal = { offset: -axis.elevation, elevation: axis.offset }; - if (normal.elevation < 0) normal = { offset: -normal.offset, elevation: -normal.elevation }; - topAnchor = { - offset: pipeStart.offset + normal.offset * diameter, - elevation: pipeStart.elevation + normal.elevation * diameter, - }; - }; - deriveAxis(); - const endCorners = (role: "inlet" | "outlet", endPoint: OffsetPoint): PipeEnd => { - const fallback: PipeEnd = { - bottom: endPoint, - top: { - offset: endPoint.offset + normal.offset * diameter, - elevation: endPoint.elevation + normal.elevation * diameter, - }, - }; - const face = endFaces[role]; - if (!face) return fallback; - const bottom = intersect(face.base, face.direction, pipeStart, axis); - const top = intersect(face.base, face.direction, topAnchor, axis); - if (!bottom || !top) return fallback; - const strayed = - Math.hypot(bottom.offset - endPoint.offset, bottom.elevation - endPoint.elevation) > - STRAY_LIMIT_M || - Math.hypot(top.offset - fallback.top.offset, top.elevation - fallback.top.elevation) > - STRAY_LIMIT_M; - if (strayed) return fallback; - return { bottom: clampToFace(bottom, face), top: clampToFace(top, face) }; - }; + const pipeAxis = pipeAxisSolver(pipeStart, outlet, diameter, endFaces); + const deriveAxis = pipeAxis.derive; + const endCorners = pipeAxis.corners; let pipeCorners = { inlet: endCorners("inlet", pipeStart), outlet: endCorners("outlet", outlet), @@ -556,7 +533,7 @@ export function computeCulvertLayout( const gap = target - cutLength(pipeCorners); if (Math.abs(gap) < 0.005) break; // ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위). - const moved = outletWallAnchor.offset + axis.offset * gap; + const moved = outletWallAnchor.offset + pipeAxis.axis.offset * gap; const movedSlope = outletSlopeAt(moved, outletThickness); // 사면 역전·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저다. if ( @@ -651,7 +628,7 @@ export function computeCulvertLayout( inletOptions, culvert, pipe: { inlet: pipeStart, outlet, lengthM, slopePct }, - pipeAxis: { axis, normal }, + pipeAxis: { axis: pipeAxis.axis, normal: pipeAxis.normal }, pipeCorners, walls, fillSlope: { @@ -671,6 +648,7 @@ export function computeCulvertLayout( outletFill: { segments: extras.segments, addable: extras.addable }, basinFill: { segments: basinExtras.segments, addable: basinExtras.addable }, basinExtras: basinExtras.walls, + basinAdjust, inletFill, extraWalls: extras.walls, basin, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 9d12f548..b7b030e6 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -15,7 +15,12 @@ import { REVET_THICKNESS_M, REVET_TRAP_TOP_M, } from "./B06_Section_UI_Cross_Culvert_Const"; -import type { OffsetPoint, PipeEnd, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { + EndFace, + OffsetPoint, + PipeEnd, + WallAdjust, +} from "./B06_Section_UI_Cross_Culvert_Types"; import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; /** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */ @@ -492,3 +497,64 @@ export function placeInletWall(input: { } return placed; } + +/** 관 축·법선·양 끝단 꼭짓점을 한 번에 푸는 도구(본체 700줄 제한 — 2026-08-22). */ +export interface PipeAxisSolver { + axis: OffsetPoint; + normal: OffsetPoint; + /** 벽이 옮겨진 뒤 축을 다시 유도한다. */ + derive: () => void; + /** 끝단면(구조물 계류측 변)으로 자른 꼭짓점 — 면이 없거나 크게 벗어나면 폴백. */ + corners: (role: "inlet" | "outlet", endPoint: OffsetPoint) => PipeEnd; +} + +export function pipeAxisSolver( + pipeStart: OffsetPoint, + outlet: OffsetPoint, + diameter: number, + endFaces: { inlet: EndFace | null; outlet: EndFace | null }, +): PipeAxisSolver { + const solver: PipeAxisSolver = { + axis: { offset: 1, elevation: 0 }, + normal: { offset: 0, elevation: 1 }, + derive: () => undefined, + corners: () => ({ bottom: pipeStart, top: pipeStart }), + }; + let topAnchor: OffsetPoint = pipeStart; + solver.derive = (): void => { + const runP = outlet.offset - pipeStart.offset; + const riseP = outlet.elevation - pipeStart.elevation; + const axisLength = Math.hypot(runP, riseP) || 1; + solver.axis = { offset: runP / axisLength, elevation: riseP / axisLength }; + const normal = { offset: -solver.axis.elevation, elevation: solver.axis.offset }; + solver.normal = + normal.elevation < 0 ? { offset: -normal.offset, elevation: -normal.elevation } : normal; + topAnchor = { + offset: pipeStart.offset + solver.normal.offset * diameter, + elevation: pipeStart.elevation + solver.normal.elevation * diameter, + }; + }; + solver.corners = (role, endPoint): PipeEnd => { + const fallback: PipeEnd = { + bottom: endPoint, + top: { + offset: endPoint.offset + solver.normal.offset * diameter, + elevation: endPoint.elevation + solver.normal.elevation * diameter, + }, + }; + const face = endFaces[role]; + if (!face) return fallback; + const bottom = intersect(face.base, face.direction, pipeStart, solver.axis); + const top = intersect(face.base, face.direction, topAnchor, solver.axis); + if (!bottom || !top) return fallback; + const strayed = + Math.hypot(bottom.offset - endPoint.offset, bottom.elevation - endPoint.elevation) > + STRAY_LIMIT_M || + Math.hypot(top.offset - fallback.top.offset, top.elevation - fallback.top.elevation) > + STRAY_LIMIT_M; + if (strayed) return fallback; + return { bottom: clampToFace(bottom, face), top: clampToFace(top, face) }; + }; + solver.derive(); + return solver; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index cdb5dfe2..e374e0e2 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -178,6 +178,8 @@ export interface CulvertLayout { basinFill: { segments: OutletFillSegment[]; addable: boolean }; /** 집수정 계류측 다단 기슭막이 — 조정창 키 `bextra{n}`. */ basinExtras: WallLayout[]; + /** 한계에 잘린 뒤의 **실제 적용된** 집수정 조작값 — 조정창이 되받는다. */ + basinAdjust: BasinAdjust; /** 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위면 0도 성토선(지반 정지), * 아래면 0도 1m 후 표준 절토 경사선. 집수정·접속 불필요면 null. */ inletFill: OutletFillSegment | null; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 71d39e2e..757e346c 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -89,6 +89,19 @@ export function computeCardCulvert( equalizeExtras, ); if (!layout) return null; + // I형 집수정 이동 한계 — 기하가 관 길이가 바뀌지 않는 자리로 잘라 돌려준다. + // 잘렸으면 저장값을 실제 적용값으로 되돌리고(숫자만 커지는 것 방지) 이유를 알린다. + if (inletStructure) { + const requested = inletStructure.adjustFor(section); + const applied = layout.basinAdjust; + const moved = + Math.abs(requested.lateralM - applied.lateralM) > 0.05 || + Math.abs(requested.slopeM - applied.slopeM) > 0.05; + if (moved) { + if (activeRevet === "inlet") showToast(L("B06_Cross_Basin_Limit_Pipe"), "info"); + inletStructure.updateAdjust(section.chainage_m, applied); + } + } if (extraWalls && extraWalls.countFor(section, "basin") > layout.basinExtras.length) { extraWalls.syncCount(section.chainage_m, layout.basinExtras.length, "basin"); } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index ea5a0655..d07b940f 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -275,6 +275,10 @@ export const ui_locales_b2 = { "{mat} height limit {limit}m — change material to go higher", ], B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"], + B06_Cross_Basin_Limit_Pipe: [ + "여기까지입니다 — 더 옮기면 배관 길이가 달라집니다(I형은 관을 감싸는 구조)", + "Limit reached — moving further changes the pipe length (type I wraps the pipe)", + ], B06_Cross_Mat_Label: ["재질", "Material"], B06_Cross_Mat_Dry: ["메쌓기", "Dry masonry"], B06_Cross_Mat_Wet: ["찰쌓기", "Wet masonry"],