From 6ba26ee61cecf0a7acfa88b604132d01e078f88a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 12:12:43 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(B06=20=EB=B2=BD):=20371-1=20=EC=84=B1?= =?UTF-8?q?=ED=86=A0=20=EC=9C=84=20=EC=9D=B4=EC=9D=8C=20=3D=20=EC=9C=97?= =?UTF-8?q?=EB=A9=B4=20=EC=82=AC=EB=A9=B4=20=EC=AA=BD=20=EB=81=9D=EC=97=90?= =?UTF-8?q?=EC=84=9C=200.1=20m=20=C2=B7=20=EB=8B=A4=EB=8B=A8=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=20=EC=8B=9C=EC=9E=91=20=3D=20=EC=9C=97=EB=8B=A8=20?= =?UTF-8?q?=EB=B0=9C=EB=81=9D=20=C2=B7=20=EA=B8=B0=EB=B3=B8=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EC=83=88=20=EC=9D=B4=EC=9D=8C=20=EA=B8=B0=EC=A4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 벽 fillTop 칸 · 성토 사면선 · 트림 · 3D 실루엣 목표 topJoint → fillTop(무늬 이음선 그대로) - 0점 · 다단 자동 자리 · 최소 노견 자리 = 배면에서 1.5t − 0.1(노폭 연장 0 유지 · 관 길이 7.9 → 7.5 m) - 다단 사이 = 늘 윗단 발끝(원지반 무관) · 끝 구간 · 관 유출 벽 = 발끝이 지반 위에 뜬 벽만 발끝 - 시험 186_1 · 139_6 · 225_1 · 139_4 새 수치 · wall_shape_unified 이음 0.1 m 확인 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- ...5_Profile_UI_Corridor_Station_Structure.ts | 8 +-- .../B06_Section_UI_Cross_Culvert_Extra.ts | 49 +++++++++++++------ .../B06_Section_UI_Cross_Culvert_Geom.ts | 29 ++++++++--- .../B06_Section_UI_Cross_Culvert_Tiers.ts | 6 +-- .../B06_Section_UI_Cross_Culvert_Types.ts | 4 +- B06_Section/B06_Section_UI_Cross_Revetment.ts | 6 +-- B06_Section/B06_Section_UI_Cross_Wall.ts | 2 + .../B06_Section_UI_Cross_Culvert_Const.ts | 6 +++ .../B06_Section_UI_Cross_Culvert_Solve.ts | 8 +-- .../tester/helper_139_4_buried_outlet.cjs | 4 +- resources/tester/test_139_4_buried_outlet.py | 7 ++- resources/tester/test_139_6_pipe_fit.py | 7 +-- resources/tester/test_186_1_outlet_fold.py | 6 ++- resources/tester/test_225_1_pipe_round.py | 3 +- .../tester/test_b06_wall_shape_unified.py | 8 +++ 15 files changed, 107 insertions(+), 46 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts index 8a22ff43b..cac55c2ca 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts @@ -308,8 +308,8 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] const side: "left" | "right" = wall.outward > 0 ? "left" : "right"; const slope = trimSlopeOf(side); if (slope) pushTopSlope(side, slope.points); - // 구조물 전면 실루엣 — 이음선 상단에서 전면 발끝까지 내려온다. - push(side, [wall.topJoint, wall.bottomFront]); + // 구조물 전면 실루엣 — 성토 위 이음에서 전면 발끝까지 내려온다(371-1). + push(side, [wall.fillTop, wall.bottomFront]); spans[side] = wallSpanOf(section, wall.role === "inlet" ? "inlet" : "outlet"); } const outletSegments = layout.outletFill.segments.filter((segment) => segment.kind !== "cut"); @@ -321,13 +321,13 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] addRun(outletSide, segment.points); } const wall = layout.extraWalls[step]; - if (wall) push(outletSide, [wall.topJoint, wall.bottomFront]); + if (wall) push(outletSide, [wall.fillTop, wall.bottomFront]); } layout.basinFill.segments.forEach((segment) => { push(inletSide, segment.points); addRun(inletSide, segment.points); }); - layout.basinExtras.forEach((wall) => push(inletSide, [wall.topJoint, wall.bottomFront])); + layout.basinExtras.forEach((wall) => push(inletSide, [wall.fillTop, wall.bottomFront])); let basinCut = false; if (layout.basin) { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 406e38e33..facb70214 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -6,8 +6,8 @@ * (2026-08-22 사용자 확정 규칙): * · 기슭막이 사이 성토사면은 **정확히 1:1.2** · 자동 자리 = 사면 1.0 m 뒤 벽 상단(원지반 무관 · 91-3). * · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**. - * · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:n · 표준경사)의 - * 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다). + * · 다음 단 성토선 시작점 = 윗단 벽 **발끝**(하단 수평선 ∩ 전면 경사선 · 원지반 무관 · 371-1). + * 마지막 벽 → 끝 구간만 하단 수평선 +0.5m ∩ 전면(관 하단 꼭짓점과 같은 자리) · 뜬 벽은 발끝. * ========================================================================== */ import { @@ -19,6 +19,7 @@ import { materialFromForm, revetFormOf, REVET_EMBED_DEPTH_M, + REVET_FILL_TOP_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, WALL_MIN_PURE_HEIGHT_M, @@ -208,11 +209,25 @@ export function inletGroundConnector( return null; } -/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이·기울기에 따라 커진다. */ +/** 벽 중심(하단 중점)에서 성토 위 이음까지의 수평거리 — 높이·기울기에 따라 커진다(371-1). */ function jointRunOf(height: number, lean: number): number { - return 0.25 * REVET_THICKNESS_M + (lean / 2) * height; + return 0.75 * REVET_THICKNESS_M - REVET_FILL_TOP_M + (lean / 2) * height; } +/** 벽 아래 성토 시작점(371-1) — 아래 단이 있으면(`tiered`) 늘 발끝(전면 ∩ 하단 수평선 · + * 원지반 무관) · 끝 구간은 발끝이 원지반 위에 뜬 벽만 발끝 · 땅에 선 · 묻힌 벽은 `grounded` + * (기준선 ∩ 전면 · 묻힘 판정 그대로). */ +export const floatFillStart = ( + wall: WallLayout, + grounded: OffsetPoint, + groundAt: (offset: number) => number, + tiered: boolean, +): OffsetPoint => + tiered || + (wall.floatGapM > 1e-6 && groundAt(wall.bottomFront.offset) < wall.bottomFront.elevation - 0.01) + ? wall.bottomFront + : grounded; + /** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */ function trailingFill( src: OffsetPoint, @@ -359,22 +374,28 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult ? { offset: src.offset + outward * shelfRun, elevation: src.elevation } : null; const slopeFrom = shelf ?? src; - const run = Math.abs(wall.topJoint.offset - slopeFrom.offset); - const rise = slopeFrom.elevation - wall.topJoint.elevation; + const run = Math.abs(wall.fillTop.offset - slopeFrom.offset); + const rise = slopeFrom.elevation - wall.fillTop.elevation; segments.push({ - points: shelf ? [src, shelf, wall.topJoint] : [src, wall.topJoint], + points: shelf ? [src, shelf, wall.fillTop] : [src, wall.fillTop], 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 사용자 — 관 끝점 규칙과 동일 기준). + // 다음 단 시작점 = 벽 발끝(전면 경사 ∩ 하단 수평선 · 원지반 무관 · 371-1). 마지막 벽 → + // 끝 구간은 바닥 +0.5m(= 기준선) ∩ 전면(2026-08-22 사용자) · 뜬 벽만 발끝. const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; - src = { - offset: - wall.points[2].offset + outward * wall.lean * (wall.topJoint.elevation - startElevation), - elevation: startElevation, - }; + src = floatFillStart( + wall, + { + offset: + wall.points[2].offset + + outward * wall.lean * (wall.topJoint.elevation - startElevation), + elevation: startElevation, + }, + groundAt, + i < input.adjusts.length - 1, + ); prevBottom = wall.bottomBack.elevation; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index dee524084..cab7ffa11 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -57,7 +57,7 @@ import { BASIN_INNER_WIDTH_M, inletChoiceAvailability, } from "./logic/B06_Section_UI_Cross_Culvert_Basin"; -import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { floatFillStart, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import { withBackCut } from "./logic/B06_Section_UI_Cross_Structure_Cut"; import { buildCulvertTiers, @@ -377,17 +377,17 @@ export function computeCulvertLayout( lean, roadElevation: side.elevation_m, }); - const topJoint = wall.topJoint.offset; - const topElevation = wall.topJoint.elevation; + const fillTop = wall.fillTop.offset; + const topElevation = wall.fillTop.elevation; const topFront = wall.points[2].offset; const bottomFront = wall.bottomFront; walls.push(wall); - // 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ②). + // 성토 사면선은 **성토 위 이음**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ② · 371-1). if (outward > 0) { - trimMax = topJoint; + trimMax = fillTop; trimMaxElevation = topElevation; } else { - trimMin = topJoint; + trimMin = fillTop; trimMinElevation = topElevation; } // 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③). @@ -507,11 +507,24 @@ export function computeCulvertLayout( // 다단 흙막이(유출측 · 집수정 계류측) — `_Culvert_Tiers` 가 푼다. const { extras, basinExtras, extrasForArea, basinExtrasForArea } = buildCulvertTiers({ section, - outletStart: outletWall ? pipeCorners.outlet.bottom : null, + outletStart: outletWall + ? floatFillStart( + outletWall, + pipeCorners.outlet.bottom, + groundAt, + (revetShift?.extras ?? []).length > 0, + ) + : null, outletBottom: outletWall?.bottomBack.elevation ?? 0, basinStart: culvert.hidden_pipe ? inletWall - ? (guardFloorStart(section, body, inletWall) ?? pipeCorners.inlet.bottom) + ? (guardFloorStart(section, body, inletWall) ?? + floatFillStart( + inletWall, + pipeCorners.inlet.bottom, + groundAt, + (revetShift?.basinExtras ?? []).length > 0, + )) : null : basinFillStart, basinBottom: culvert.hidden_pipe ? (inletWall?.bottomBack.elevation ?? 0) : basinFillBottom, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts index 06c103e63..234def22d 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts @@ -9,7 +9,7 @@ import type { CrossSection } from "./B06_Section_Api_Fetch"; import { GUARD_CUT_BERM_M, REVET_EMBED_DEPTH_M, - REVET_TRAP_TOP_M, + REVET_FILL_TOP_M, } from "./logic/B06_Section_UI_Cross_Culvert_Const"; import type { OutletExtrasResult } from "./B06_Section_UI_Cross_Culvert_Extra"; import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; @@ -26,7 +26,7 @@ export const slopeZeroAnchor = ( outward: number, wallHeight: number, lean: number, -): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M); +): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_FILL_TOP_M); /** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의 * 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */ @@ -106,7 +106,7 @@ export function buildCulvertTiers(input: { }; const extras = buildExtrasAt(input.outletStart, outletInput); // 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 — - // 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자). + // 유출측(`extras`)과 같은 시작점(`floatFillStart` · 371-1)·같은 `bextra` 키(2026-08-29 사용자). const basinInput = { cutBerm: cutBerm(culvert?.body ? (culvert.inlet.revet_berm_m ?? GUARD_CUT_BERM_M) : 0), startBottomElevation: input.basinBottom, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 65c5dae08..ac4aec3d0 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -123,8 +123,10 @@ export interface WallLayout { points: OffsetPoint[]; /** 상단 배면 꼭짓점 — 성토 사면선과의 접점(설계선 트림 경계). */ topBack: OffsetPoint; - /** 이음선 상단점(평행사변형↔사다리꼴 상단 교차점) — 성토 사면선이 여기서 끝난다. */ + /** 이음선 상단점(평행사변형↔사다리꼴 상단 교차점) — 안 무늬 이음선 자리. */ topJoint: OffsetPoint; + /** 성토 위 이음 — 윗면 사면 쪽 끝에서 0.1 m 안(371-1). 성토 사면선이 여기서 끝난다. */ + fillTop: OffsetPoint; /** 하단선 양 끝 — **수평 기초**(2026-08-22 사용자 확정): 발자국 최저 지반(또는 관 * invert) − 0.5m 표고의 수평선. 전면 발끝은 1:0.3 경사선을 하단까지 연장한 자리다. */ bottomBack: OffsetPoint; diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index d664d6bdd..7164088df 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -22,7 +22,7 @@ import { FILL_SLOPE_RATIO_MIN, PIPE_WALL_DEFAULT_RUN_M, REVET_EMBED_DEPTH_M, - REVET_THICKNESS_M, + REVET_FILL_TOP_M, WALL_MIN_PURE_HEIGHT_M, } from "./logic/B06_Section_UI_Cross_Culvert_Const"; import { outlineLeanOf } from "./logic/B06_Section_UI_Cross_Lean"; @@ -208,7 +208,7 @@ export function computeRevetmentLayout( }); const roadSlope = roadSlopePerOutward(design, side, outward); const autoOffset = - edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2); + edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_FILL_TOP_M); // x 원점 = 성토사면(1:1.2) 경사길이 4m 지점 — 저장된 x 의 절대 자리 그대로(79b). const autoRun = PIPE_WALL_DEFAULT_RUN_M; /** @@ -285,7 +285,7 @@ export function computeRevetmentLayout( const walls: WallLayout[] = [tier1, ...extras.walls]; // 물빼기 파이프 단면 = 표 형상칸.weep(322-1 · 옹벽) if (catalogRow(spec.type_id)?.shapeOpts.weep) for (const wall of walls) wall.retaining = true; - const jt = tier1.topJoint; + const jt = tier1.fillTop; // 노견 → (물매를 탄 노견 연장) → 벽 이음선 상단점. 연장 구간은 노면 물매 그대로 가고, // 꺾임점에서 성토 물매 1:1.2로 벽 상단에 닿는다(2026-08-29 사용자 확정). // D = 노견에서 벽 상단까지 바깥 방향 거리, R0 = 노견과 벽 상단의 낙차 diff --git a/B06_Section/B06_Section_UI_Cross_Wall.ts b/B06_Section/B06_Section_UI_Cross_Wall.ts index 2acbf3b55..ae56ba825 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall.ts @@ -14,6 +14,7 @@ import { REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, + WALL_FILL_TOP_INSET_M, WALL_MIN_PURE_HEIGHT_M, } from "./logic/B06_Section_UI_Cross_Culvert_Const"; import type { RevetMaterial } from "./logic/B06_Section_UI_Cross_Culvert_Const"; @@ -104,6 +105,7 @@ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayou outward, topBack, topJoint: { offset: topJoint, elevation: topElevation }, + fillTop: { offset: topFront - outward * WALL_FILL_TOP_INSET_M, elevation: topElevation }, bottomBack, bottomFront, points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront], diff --git a/B06_Section/logic/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/logic/B06_Section_UI_Cross_Culvert_Const.ts index 12191aff8..dcc50e510 100644 --- a/B06_Section/logic/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/logic/B06_Section_UI_Cross_Culvert_Const.ts @@ -31,6 +31,12 @@ export const REVET_LEAN_RATIO = 0.3; /** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */ export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2; +/** 성토 위 이음 = 벽 윗면 사면 쪽 끝에서 이만큼 안(m · 371-1). */ +export const WALL_FILL_TOP_INSET_M = 0.1; + +/** 배면에서 성토 위 이음까지 수평거리(m) — 윗면 1.5t − 0.1(371-1). */ +export const REVET_FILL_TOP_M = REVET_THICKNESS_M * 1.5 - WALL_FILL_TOP_INSET_M; + /** * 집수정 상부 여유(m) — 관 상단에서 집수정 벽 상단까지의 간격. 관 벽(유입·유출)은 * 여유 없이 벽 윗면 = 관 윗면(139-1 · `revetTargetHeight`). diff --git a/B06_Section/logic/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/logic/B06_Section_UI_Cross_Culvert_Solve.ts index db9a03ed9..28bb3f116 100644 --- a/B06_Section/logic/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/logic/B06_Section_UI_Cross_Culvert_Solve.ts @@ -11,7 +11,7 @@ import { FILL_SLOPE_RATIO_MIN, REVET_LEAN_RATIO, REVET_THICKNESS_M, - REVET_TRAP_TOP_M, + REVET_FILL_TOP_M, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { EndFace, @@ -144,10 +144,10 @@ export interface FillSlopeSegment { * 접점이 다시 어긋난다. */ export function fillSlopeOf( - wall: { role: "inlet" | "outlet" | "extra"; topJoint: OffsetPoint; outward: number }, + wall: { role: "inlet" | "outlet" | "extra"; fillTop: OffsetPoint; outward: number }, edge: { offset_m: number; elevation_m: number }, ): FillSlopeSegment { - const to = wall.topJoint; + const to = wall.fillTop; const rise = edge.elevation_m - to.elevation; const edgePoint: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m }; if (!(rise > 1e-6)) { @@ -244,7 +244,7 @@ export function minShoulderWallOffset( const offset = edge.offset_m + outward * ((span * i) / steps); const rise = edge.elevation_m - (baseAt(offset) + height); if (rise < FILL_MIN_RISE_M) continue; - const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M); + const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_FILL_TOP_M); if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue; if ((offset - toeOffset) * outward > 1e-6) { const toeRise = edge.elevation_m - (baseAt(toeOffset) + height); diff --git a/resources/tester/helper_139_4_buried_outlet.cjs b/resources/tester/helper_139_4_buried_outlet.cjs index 9cc617d61..8b8a20cb2 100644 --- a/resources/tester/helper_139_4_buried_outlet.cjs +++ b/resources/tester/helper_139_4_buried_outlet.cjs @@ -131,7 +131,8 @@ function reshaped(rise, slope) { const s = clone(pipe); for (const p of s.samples) { const t = ((p.offset_m ?? 0) - start.offset) * outward; - if (t >= 0 && typeof p.elevation_m === "number") + // 시작점 바로 앞 표본까지(−1 m) — 보간이 시작점을 옛 지반에 두지 않게(371-1 벽 자리 이동) + if (t >= -1 && typeof p.elevation_m === "number") p.elevation_m = start.elevation + rise + slope * t; } return study( @@ -141,4 +142,5 @@ function reshaped(rise, slope) { } out.pipeDown = reshaped(0.3, -0.15); // 3도보다 가파르게 내림 → 3도 선이 만남 out.pipeUp = reshaped(0.3, 0.1); // 오르막 → 3도 선이 못 만남 → 복합경사 +out.pipeOpen = reshaped(-0.3, -0.5); // 끝 바깥이 지반 아래로 열림 → 성토부선(371-1) process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_139_4_buried_outlet.py b/resources/tester/test_139_4_buried_outlet.py index 6ddb7d15e..259e980a9 100644 --- a/resources/tester/test_139_4_buried_outlet.py +++ b/resources/tester/test_139_4_buried_outlet.py @@ -54,11 +54,14 @@ def test_3도_선이_원지반을_못_만나면_복합경사(out: dict) -> None: assert len(study["cut"]) == 1 tail = study["cut"][0] assert tail["rises"] is True and tail["grade"] == -1 and tail["endGap"] == 0 - assert study["cutArea"] > study["cutAreaWithout"] + # 371-1 — 벽 자리 0.35 m 도로 쪽 · 오르는 선 0.3 m 라 면적 차가 백엔드 설계선 끝에 묻힘 → 폐회로에 듦만 봄 + assert study["inTrim"] is True def test_성토_끝은_그대로(out: dict) -> None: - assert out["pipeBase"] == ["fill", "fill"] # 묻히지 않은 기본 = 성토부선 그대로 + # 371-1 — 다단 사이 시작 = 윗단 발끝 · 단이 0.5 m 내려가 기본 자료 끝 벽이 0.3 m 묻힘 → 3도 선 + assert out["pipeBase"] == ["fill", "cut"] + assert out["pipeOpen"]["cut"] == [] # 묻히지 않은 끝 = 성토부선 그대로 def test_유출_절토선은_그리고_면적에_넣음() -> None: diff --git a/resources/tester/test_139_6_pipe_fit.py b/resources/tester/test_139_6_pipe_fit.py index d2959f13a..320542c0a 100644 --- a/resources/tester/test_139_6_pipe_fit.py +++ b/resources/tester/test_139_6_pipe_fit.py @@ -40,10 +40,11 @@ def out(tmp_path_factory) -> dict: def test_안_넓힌_측점은_종전_그대로(out: dict) -> None: - """225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8).""" + """225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8). + 371-1 — 기본 자리 = 성토 위 이음(끝에서 0.1 m) 기준 · 벽 0.35 m 도로 쪽(5.772 → 5.422).""" base = out["base"] - assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.8) - assert base["cut"] == pytest.approx(5.772) + assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.4) + assert base["cut"] == pytest.approx(5.422) @pytest.mark.parametrize("name, extend", [("shifted", 1.0), ("shifted2", 1.3)]) diff --git a/resources/tester/test_186_1_outlet_fold.py b/resources/tester/test_186_1_outlet_fold.py index 4a173b744..cae1cb7bb 100644 --- a/resources/tester/test_186_1_outlet_fold.py +++ b/resources/tester/test_186_1_outlet_fold.py @@ -61,10 +61,12 @@ def test_묻힌_유출_벽_설계선이_안_접힘(out: dict, case: str) -> None def test_저장값_사본_면적(out: dict) -> None: """접힌 폐회로(절토 27.94 · 성토 0.34 ㎡) → 노견~벽~3도 선 절토만.""" - assert out["stored"]["cut"] == pytest.approx(34.0391, abs=1e-3) + # 371-1 — 성토 위 이음 = 윗면 끝에서 0.1 m · 기본 자리 새 이음 기준(34.0391 → 34.2539) + assert out["stored"]["cut"] == pytest.approx(34.2539, abs=1e-3) assert out["stored"]["fill"] == 0 # 225-1 — 유출 벽이 관 길이 맞춤으로 밀리지 않고 기본 자리(16.8211 → 16.0929) - assert out["auto"]["cut"] == pytest.approx(16.0929, abs=1e-3) + # 371-1 — 기본 자리 = 새 이음 기준(16.0929 → 16.2613) + assert out["auto"]["cut"] == pytest.approx(16.2613, abs=1e-3) def test_절토_쪽_배관_유출도_안_접힘(out: dict) -> None: diff --git a/resources/tester/test_225_1_pipe_round.py b/resources/tester/test_225_1_pipe_round.py index e0dabdb2f..004a21a43 100644 --- a/resources/tester/test_225_1_pipe_round.py +++ b/resources/tester/test_225_1_pipe_round.py @@ -72,7 +72,8 @@ def test_좌우_한_번은_d_를_지금_자리로_묶음(out: dict) -> None: def test_관_길이는_벽_자리에서_0_1m_로(out: dict) -> None: """유출 벽을 0.1 m 씩 밀면 관 길이도 0.1 m 씩 — 정수 올림 · 길이 맞춤 이동 없음.""" lengths = [case["lengthM"] for case in out["moved"]] - assert lengths == [7.9, 8.0, 8.1, 8.2] + # 371-1 — 기본 자리 = 성토 위 이음 기준 · 벽 0.35 m 도로 쪽(7.9 → 7.5) + assert lengths == [7.5, 7.6, 7.7, 7.8] for case in out["moved"]: assert case["lengthM"] == round(case["cut"], 1) diff --git a/resources/tester/test_b06_wall_shape_unified.py b/resources/tester/test_b06_wall_shape_unified.py index c64546122..1f56901bd 100644 --- a/resources/tester/test_b06_wall_shape_unified.py +++ b/resources/tester/test_b06_wall_shape_unified.py @@ -36,6 +36,9 @@ writeFileSync(process.argv[2], JSON.stringify({ under: buildRevetWallGeometry({ anchor, outward: 1, height: 2, material: "dry", role: "outlet", roadElevation: 103, }).topJoint.elevation, + fill: (({ fillTop, topJoint, points }) => [fillTop.offset, fillTop.elevation, topJoint.offset, points[2].offset, points[2].elevation])( + buildRevetWallGeometry({ anchor, outward: 1, height: 2.5, material: "dry", role: "outlet" }), + ), dry: revetWallSpec({ revet_form: "돌쌓기(메)" }, { h: 3.0 }, true, 0.3).pureHeight, concrete: revetWallSpec({ revet_form: "콘크리트" }, { h: 7.5 }, true, 0.3).pureHeight, })); @@ -84,6 +87,11 @@ def test_벽_외곽_한_벌_높이_입력_그대로_노면_멈춤(tmp_path: Path assert got["under"] == pytest.approx(102.0), "노면 아래 벽이 잘림" assert got["dry"] == pytest.approx(3.0), "돌쌓기(메) 3.0 이 잘림" assert got["concrete"] == pytest.approx(7.5), "콘크리트 7.5 가 잘림" + # 371-1 — 성토 위 이음 = 윗면 사면 쪽 끝에서 0.1 m 안 · 무늬 이음선(topJoint)은 그대로 + fill_offset, fill_elevation, joint_offset, front_offset, front_elevation = got["fill"] + assert fill_offset == pytest.approx(front_offset - 0.1), "성토 위 이음이 끝에서 0.1 m 아님" + assert fill_elevation == pytest.approx(front_elevation), "성토 위 이음이 윗면 표고 아님" + assert joint_offset == pytest.approx(front_offset - 0.45), "무늬 이음선이 옮겨짐" def test_네_벽이_공용_함수로_서고_형태_한도가_없다() -> None: From fedb5bc166f309982ba09fa8a8bfb5d08de9b06d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 12:15:32 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(B06=20=EA=B4=80=20=EA=B8=80):=20371-2?= =?UTF-8?q?=20=ED=9A=A1=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20=EA=B4=80=20?= =?UTF-8?q?=EA=B8=80=20L=20=EB=92=A4=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20S=3Dx?= =?UTF-8?q?.x%=20=C2=B7=20=ED=88=B4=ED=8C=81=20=EB=A7=A4=EC=84=A4=20?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=20=EC=A7=80=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「관매설 …, L=8 m (7.5), S=0.9%」 소수 한 자리 · 역경사 0.0 · 관 숨김(세월교 · 박스 흙막이) · B07 덧그림 글 없음 - 시험 test_371_2_pipe_slope_text · helper_371_2_pipe_slope Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 4 +- resources/tester/helper_371_2_pipe_slope.cjs | 120 ++++++++++++++++++ .../tester/test_371_2_pipe_slope_text.py | 49 +++++++ 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 resources/tester/helper_371_2_pipe_slope.cjs create mode 100644 resources/tester/test_371_2_pipe_slope_text.py diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 9d53cf141..ee2e95d3c 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -313,12 +313,12 @@ export function appendCulvertOverlay( const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : ""; const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter); - const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}`; + // 관 기울기 = L 뒤 소수 한 자리 · 역경사는 0.0(371-2) + const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}, S=${Math.max(pipe.slopePct, 0).toFixed(1)}%`; // 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1 · B06 카드만 — 그 라벨 없는 덧그림엔 안 그림) if (!hidePipe) appendPipeInfoText(layer, pipeInfo, null); const tip = pipeInfo + - ` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` + ` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` + ` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`; // 외경(관 두께 바깥) → 내경 순으로 2겹. 도면처럼 관벽 두께가 보이게 한다(사용자 ③). diff --git a/resources/tester/helper_371_2_pipe_slope.cjs b/resources/tester/helper_371_2_pipe_slope.cjs new file mode 100644 index 000000000..23be4ef11 --- /dev/null +++ b/resources/tester/helper_371_2_pipe_slope.cjs @@ -0,0 +1,120 @@ +/* 371-2 관 글 기울기 헬퍼 — 짝 = `test_371_2_pipe_slope_text.py`. + * B07 픽스처 배관 측점에 `appendCulvertOverlay` 를 가짜 SVG 로 그려 관 글 · 툴팁을 읽음. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const noop = new Proxy({}, { get: () => () => "" }); + +/** 아주 작은 SVG 가짜 — 글 자리 판정에 쓰는 것만(class 고르기 · 속성 · 부모). */ +class El { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.parentNode = null; + this.ownerSVGElement = null; + this.textContent = ""; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + appendChild(c) { + c.parentNode = this; + c.ownerSVGElement = this.tag === "svg" ? this : this.ownerSVGElement; + this.children.push(c); + return c; + } + append(...cs) { + cs.forEach((c) => this.appendChild(c)); + } + addEventListener() {} + all() { + return this.children.flatMap((c) => [c, ...c.all()]); + } + querySelectorAll(sel) { + return this.all().filter((e) => (e.attrs.class ?? "").split(" ").includes(sel.slice(1))); + } + querySelector(sel) { + return this.querySelectorAll(sel)[0] ?? null; + } +} +globalThis.document = { addEventListener: () => undefined, createElementNS: (_ns, t) => new El(t) }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const resolve = (base) => + fs.existsSync(base) && fs.statSync(base).isFile() ? base : `${base}.ts`; + const req = (r) => { + if (r.startsWith("@ui/") || r.startsWith("@config/")) return noop; + if (r.startsWith("@util/")) return loadTs(resolve(path.join(ROOT, "common_util", r.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), r))); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(full, box.exports); + return box.exports; +} + +const { computeCulvertLayout } = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Geom.ts"), +); +const { appendCulvertOverlay } = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert.ts"), +); +const sections = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), +).cross_sections; +const section = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); + +/** 노폭 라벨이 있는 B06 카드 꼴 svg 에 그려 관 글 · 관 툴팁을 돌려줌. */ +function draw(layout, hidePipe) { + const svg = new El("svg"); + const road = new El("text"); + road.setAttribute("class", "b06-chart__carriageway-label"); + road.setAttribute("x", 200); + road.setAttribute("y", 180); + svg.appendChild(road); + const plot = new El("g"); + svg.appendChild(plot); + appendCulvertOverlay( + plot, + layout, + (o) => o, + (z) => z, + undefined, + hidePipe, + false, + ); + const titles = svg + .all() + .filter((e) => e.tag === "title") + .map((e) => e.textContent); + return { + infos: svg.querySelectorAll(".b06-chart__pipe-info").map((e) => e.textContent), + pipeTips: titles.filter((t) => t.startsWith("관매설")), + }; +} +const layout = computeCulvertLayout(section, section.samples); +const reverse = { ...layout, pipe: { ...layout.pipe, slopePct: -0.43 } }; +process.stdout.write( + JSON.stringify({ + slopePct: layout.pipe.slopePct, + lengthM: layout.pipe.lengthM, + pipe: draw(layout, false), + reverse: draw(reverse, false), + hidden: draw(layout, true), + }), +); diff --git a/resources/tester/test_371_2_pipe_slope_text.py b/resources/tester/test_371_2_pipe_slope_text.py new file mode 100644 index 000000000..5ce087c98 --- /dev/null +++ b/resources/tester/test_371_2_pipe_slope_text.py @@ -0,0 +1,49 @@ +"""PLAN 371-2 — B06 횡단 카드 관 글 L 뒤 기울기(요구 240-16). + +규칙: 「관매설 {관종}Φ…m/m, L=8 m (7.6), S=2.5%」(소수 한 자리 · 역경사 0.0) · +툴팁 「매설 경사」 지움 · 세월교 · 박스 흙막이 세트(hidden_pipe = 관 숨김)는 관 글 없음 · +B07 덧그림(노폭 라벨 없음)도 없음(225-1). +짝 = `helper_371_2_pipe_slope.cjs`. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_371_2_pipe_slope.cjs" + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_관_글_L_뒤에_기울기_소수_한_자리(out: dict) -> None: + slope = f"{out['slopePct']:.1f}" + assert out["pipe"]["infos"] == [f"관매설 파형강관 Φ1000m/m, L=8 m (7.5), S={slope}%"] + + +def test_역경사는_0_0(out: dict) -> None: + assert out["reverse"]["infos"][0].endswith(", S=0.0%") + + +def test_툴팁에_매설_경사_없음(out: dict) -> None: + tips = out["pipe"]["pipeTips"] + assert tips and all("매설 경사" not in tip for tip in tips) + + +def test_관_숨김_세트는_관_글_없음(out: dict) -> None: + assert out["hidden"] == {"infos": [], "pipeTips": []} From 486616aabdae29ac7579241753a4ac828bd96d5c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 12:21:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(B06=20=EB=A7=B9=EC=95=94=EA=B1=B0):=203?= =?UTF-8?q?71-3=20=EB=85=B8=EB=A9=B4=20=3D=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=20=C2=B7=20=EC=95=94=EA=B1=B0=20=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=20=3D=20=ED=98=95=EC=83=81=20=ED=95=98=EB=8B=A8=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84(=EB=86=92=EC=9D=80=20=EC=AA=BD=20=EB=85=B8?= =?UTF-8?q?=EB=A9=B4=20=EB=81=9D=20=E2=88=92=20=EB=86=92=EC=9D=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=82=B4=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 암거 경사가 노면으로 가던 길 끊음(tilt_ranges None ↔ facilityRanges null · Cross_Refresh · design_pipeline_cross) · 측구 없음 · 낮은 쪽 한 경사 그대로 - underdrainOutline 하단 = 높은 쪽 노면 끝 z − H + 암거 경사 · 절토 교점 · 성토 교점 · 돌 띠 따라감 · 경사 비면 옛 동작 - 저장분은 tilt_pct 달라 서버가 다시 셈 · B06 · B07 같은 함수(묶음 다시 지음) - 시험 test_288 하단 경계 · 349_0n 맹암거 자리 경사 비움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- A00_Common/design/design_pipeline_cross.ts | 2 +- B06_Section/B06_Section_Cross_Refresh.ts | 2 +- B06_Section/B06_Section_Engine_Tilt.py | 5 ++- .../B06_Section_UI_Cross_Underdrain.ts | 45 ++++++++++++++----- .../logic/B06_Section_Cross_Enforce.ts | 3 +- .../common_util_structure_underdrain.ts | 2 +- resources/tester/helper_288_underdrain.cjs | 2 + resources/tester/test_288_underdrain_cross.py | 34 ++++++++++---- .../test_349_0n_cross_enforce_mirror.py | 3 ++ 9 files changed, 72 insertions(+), 26 deletions(-) diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index 4ef809138..b047fcfcd 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -66,7 +66,7 @@ function carried(section: CrossSection): Record { : null, paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement), tilted: Boolean(section.ford_pavement || section.underdrain), - tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null, + tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3) surfaceDropM: drop > 0 ? drop : 0, }; } diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index d71763f7c..2f72e8c68 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -435,7 +435,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { paved: pavedBySpecs(section), // 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원) tilted: Boolean(section.ford_pavement || section.underdrain), - tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null, + tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3) standard, berm: bermAt(section.chainage_m, design), rockBoundaryOffsetM, diff --git a/B06_Section/B06_Section_Engine_Tilt.py b/B06_Section/B06_Section_Engine_Tilt.py index e67df46af..d0bd3b129 100644 --- a/B06_Section/B06_Section_Engine_Tilt.py +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -1,7 +1,7 @@ """구조물 자리 기울기 — 물넘이포장 · 맹암거 측점의 노면(287-4 · 288-1). 그 자리 횡단 설계 = 측구 없음 · 노면 = 한 경사로 지대 낮은 쪽(`compute_cross_design(tilted=…, -tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 암거 경사(%) · 비면 표준 횡단경사. +tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 = 표준 횡단경사(암거 경사는 형상 하단 · 371-3). · 물넘이포장 = 관 지점 정본(pipe_points) 범위(기준 ± 월류 폭/2). · 맹암거 = 구조물 정본(structures.json) A군 점형 · 기준 측점 한 곳 — 횡단 dict 에 `underdrain`(높이 · 경사)을 얹어 횡단도가 돌 무늬 형상을 그림(TS 짝 @@ -78,7 +78,8 @@ def tilt_ranges(project_root: Path | None) -> list[TiltRange]: half = float(spec.get("span_m") or 0.0) / 2.0 out.append((at - half, at + half, spec.get("slope_pct"))) for drain in load_underdrains(project_root): - out.append((drain["chainage_m"], drain["chainage_m"], drain["slope_pct"])) + # 노면 = 표준 경사 · 암거 경사는 맹암거 하단 경계만(371-3 · TS `facilityRanges` 짝) + out.append((drain["chainage_m"], drain["chainage_m"], None)) return out diff --git a/B06_Section/B06_Section_UI_Cross_Underdrain.ts b/B06_Section/B06_Section_UI_Cross_Underdrain.ts index cb41c0033..4b43d4798 100644 --- a/B06_Section/B06_Section_UI_Cross_Underdrain.ts +++ b/B06_Section/B06_Section_UI_Cross_Underdrain.ts @@ -2,8 +2,9 @@ * B06_Section_UI_Cross_Underdrain.ts * 맹암거 횡단 형상(288-1 · 요구 183-3). * - * · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음. - * · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도. + * · 노면 = 설계 노면 — 서버 · TS 짝이 표준 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음. + * · 옵셋선(하단 경계) = 높은 쪽 노면 끝에서 암거 높이 아래 · 암거 경사로 낮은 쪽으로 내림(371-3) · + * 경사가 비면 노면과 같은 각도. * · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남. * · 양측절토(낮은 쪽 늘인 노면 `tilt_reach`) = 늘인 노면이 원지반과 만나는 점에서 수직으로 끊음(356-1). * · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 · @@ -42,16 +43,18 @@ export function drawCrossedStones(group: SVGElement, frame: BandFrame, heightM: type Edge = { offset_m: number; elevation_m: number }; +/** 하단 경계(옵셋선) — 표고 = `z0 + grade · (o − o0)`. */ +type Bottom = { o0: number; z0: number; grade: number }; + /** 한쪽 둘레(노면 끝 → 옵셋선과 만나는 자리) — `outward` = 바깥 부호(+1 좌 · −1 우). */ function sidePath( edge: Edge, outward: 1 | -1, line: readonly Edge[], - roadGrade: number, - heightM: number, + bottom: Bottom, ): OffsetPoint[] { const start = { offset: edge.offset_m, elevation: edge.elevation_m }; - const offZ = (o: number) => edge.elevation_m + roadGrade * (o - edge.offset_m) - heightM; + const offZ = (o: number) => bottom.z0 + bottom.grade * (o - bottom.o0); const outside = line .filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6) .sort((a, b) => (a.offset_m - b.offset_m) * outward); @@ -60,7 +63,8 @@ function sidePath( if (first.elevation_m >= edge.elevation_m) { // 절토 — 사면을 안쪽 아래로 늘여 옵셋선과 만남 const k = (first.elevation_m - edge.elevation_m) / (first.offset_m - edge.offset_m); - const d = Math.abs(k - roadGrade) < 1e-9 ? 0 : -heightM / (k - roadGrade); + const g = bottom.grade; + const d = Math.abs(k - g) < 1e-9 ? 0 : (offZ(edge.offset_m) - edge.elevation_m) / (k - g); const offset = edge.offset_m + d; return [start, { offset, elevation: offZ(offset) }]; } @@ -83,25 +87,38 @@ function sidePath( return path; } +/** 하단 경계 — 높은 쪽(상류) 노면 끝 − 높이에서 암거 경사로 낮은 쪽으로 내림(371-3) · 경사가 비면 노면과 평행. */ +function bottomOf(left: Edge, right: Edge, heightM: number, slopePct: number | null): Bottom { + const roadGrade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m); + const high = left.elevation_m >= right.elevation_m ? left : right; + const low = high === left ? right : left; + const grade = + slopePct && slopePct > 0 + ? (Math.sign(low.offset_m - high.offset_m) * -slopePct) / 100 + : roadGrade; + return { o0: high.offset_m, z0: high.elevation_m - heightM, grade }; +} + /** 맹암거 형상 둘레 — 좌 노면 끝 → 우 노면 끝 → 우 둘레 → 옵셋선 → 좌 둘레(위로). 높이 없으면 null. */ export function underdrainOutline( design: CrossDesign, heightM: number | null, + slopePct: number | null = null, ): OffsetPoint[] | null { const edges = design.road_edges; if (!edges || !(heightM && heightM > 0)) return null; const { left, right } = edges; const span = left.offset_m - right.offset_m; if (!(Math.abs(span) > 1e-9)) return null; - const grade = (left.elevation_m - right.elevation_m) / span; + const bottom = bottomOf(left, right, heightM, slopePct); // 양측절토(356-1) — 낮은 쪽 늘인 노면이 원지반과 만나는 점(tilt_reach)에서 끊음 · 너머 점 거름 → 꼬리가 수직으로 닫음 const reach = design.tilt_reach; const outward = reach?.side === "left" ? 1 : -1; const line = (design.design_line ?? []).filter( (p) => !reach || (p.offset_m - reach.offset_m) * outward <= 1e-6, ); - const leftPath = sidePath(left, 1, line, grade, heightM); - const rightPath = sidePath(right, -1, line, grade, heightM); + const leftPath = sidePath(left, 1, line, bottom); + const rightPath = sidePath(right, -1, line, bottom); return [ { offset: left.offset_m, elevation: left.elevation_m }, ...rightPath, @@ -117,7 +134,7 @@ export function appendUnderdrainOverlay( x: (offset: number) => number, y: (elevation: number) => number, ): SVGGElement | null { - const outline = spec ? underdrainOutline(design, spec.height_m) : null; + const outline = spec ? underdrainOutline(design, spec.height_m, spec.slope_pct) : null; if (!spec || !outline) return null; const px = (p: OffsetPoint): [number, number] => [x(p.offset), y(p.elevation)]; const points = outline.map((p) => px(p).join(",")).join(" "); @@ -136,15 +153,19 @@ export function appendUnderdrainOverlay( clip.append(clipShape); const stones = document.createElementNS(SVG_NS, "g"); stones.setAttribute("clip-path", `url(#${clipId})`); - const height = spec.height_m!; const offsets = outline.map((p) => p.offset); const [lo, hi] = [Math.min(...offsets), Math.max(...offsets)]; const { left, right } = design.road_edges; const grade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m); const roadZ = (o: number) => left.elevation_m + grade * (o - left.offset_m); + const bottom = bottomOf(left, right, spec.height_m!, spec.slope_pct); + const bottomZ = (o: number) => bottom.z0 + bottom.grade * (o - bottom.o0); + // 돌 띠 = 노면 ~ 하단 경계(두께가 자리마다 다름 · 켜 수는 두꺼운 쪽) + const height = Math.max(roadZ(lo) - bottomZ(lo), roadZ(hi) - bottomZ(hi), spec.height_m!); const at = (u: number, v: number): OffsetPoint => { const offset = lo + (hi - lo) * u; - return { offset, elevation: roadZ(offset) - height * (v + 0.5) }; + const top = roadZ(offset); + return { offset, elevation: top + (bottomZ(offset) - top) * (v + 0.5) }; }; const [ax, ay] = px(at(0, 0)); const [bx, by] = px(at(1, 0)); diff --git a/B06_Section/logic/B06_Section_Cross_Enforce.ts b/B06_Section/logic/B06_Section_Cross_Enforce.ts index 6eec745e3..8976ddb5b 100644 --- a/B06_Section/logic/B06_Section_Cross_Enforce.ts +++ b/B06_Section/logic/B06_Section_Cross_Enforce.ts @@ -63,8 +63,9 @@ export function facilityRanges( const depth = num(spec.overflow_depth_m) ?? 0; if (spec.type === "ford" && depth > 0) drops.push([at, depth]); } + // 노면 = 표준 경사 · 암거 경사는 맹암거 하단 경계만(371-3 · 파이썬 `tilt_ranges` 짝) for (const drain of underdrainSpecsFrom(structures)) - tilt.push([drain.chainage_m, drain.chainage_m, drain.slope_pct]); + tilt.push([drain.chainage_m, drain.chainage_m, null]); return { paved, tilt, drops }; } diff --git a/common_util/common_util_structure_underdrain.ts b/common_util/common_util_structure_underdrain.ts index e992610e6..36179f3b3 100644 --- a/common_util/common_util_structure_underdrain.ts +++ b/common_util/common_util_structure_underdrain.ts @@ -12,7 +12,7 @@ import { catalogRow } from "./common_util_structure_catalog"; const EDGE_TOLERANCE_M = 0.02; -/** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 없으면 표준 횡단경사). */ +/** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 = 형상 하단 경계 · 없으면 노면과 평행 · 371-3). */ export interface UnderdrainSpec { structure_id: string | null; chainage_m: number; diff --git a/resources/tester/helper_288_underdrain.cjs b/resources/tester/helper_288_underdrain.cjs index eaf9c50ed..bcabb6a73 100644 --- a/resources/tester/helper_288_underdrain.cjs +++ b/resources/tester/helper_288_underdrain.cjs @@ -107,6 +107,8 @@ process.stdout.write( attached, sections: sections.map((s) => s.underdrain ?? null), outline: draw.underdrainOutline(design, 1), + // 371-3 암거 경사 3 % — 하단 = 높은 쪽(좌) 노면 끝 − 1 m 에서 3 % 로 낮은 쪽(우)으로 내림 + sloped: draw.underdrainOutline(design, 1, 3), bothCut: draw.underdrainOutline(bothCut, 1), noHeight: draw.underdrainOutline(design, null), drew: !!group, diff --git a/resources/tester/test_288_underdrain_cross.py b/resources/tester/test_288_underdrain_cross.py index 50c5526c8..c048d4bf6 100644 --- a/resources/tester/test_288_underdrain_cross.py +++ b/resources/tester/test_288_underdrain_cross.py @@ -1,10 +1,12 @@ """288 맹암거 횡단 형상(요구 183-3 · 9). 못박는 것 - - 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 암거 경사로 지대 낮은 쪽 한 경사(물넘이 286 · 287 길 — - 서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted`) · 제원 얹기 서버 `attach_underdrains` ↔ TS - `attachUnderdrainSpecs` 같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과 - 만남 · 절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림. + - 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 표준 경사로 지대 낮은 쪽 한 경사 + (물넘이 286 · 287 길 — 서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted` · 암거 경사는 + 노면에 안 감 371-3) · 제원 얹기 서버 `attach_underdrains` ↔ TS `attachUnderdrainSpecs` + 같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과 만남 · + 절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림. + - 371-3 암거 경사 = 형상 하단 경계 — 높은 쪽 노면 끝 − 높이에서 암거 경사로 낮은 쪽으로 내림. - 288-2 맹암거 형상 누름 = 그 구조물 id 고름(구조물 목록 창구 · 알약 · 폼 · 측점선 따라옴) · 물넘이와 같은 판정면(보라 손잡이 아래 · 끌기 = 고름 아님 · 287-5) · 노면공 포장 범위 고름과 따로. @@ -86,8 +88,9 @@ def ts(tmp_path_factory) -> dict: def test_맹암거_자리_기울기_서버(project): ranges = tilt.tilt_ranges(project) - assert ranges == [(40.0, 40.0, 4.0), (80.0, 80.0, None)] - assert tilt.tilt_args(40.0, ranges) == {"tilted": True, "tilt_pct": 4.0} + # 371-3 — 암거 경사(4 %)는 노면에 안 감 · 노면 = 표준 경사 + assert ranges == [(40.0, 40.0, None), (80.0, 80.0, None)] + assert tilt.tilt_args(40.0, ranges) == {"tilted": True, "tilt_pct": None} assert tilt.tilt_args(80.01, ranges) == {"tilted": True, "tilt_pct": None} assert tilt.tilt_args(60.0, ranges) == {"tilted": False, "tilt_pct": None} @@ -110,8 +113,9 @@ def test_맹암거_측점_설계_측구_없음_낮은_쪽_기울기(): for o in range(-24, 25) ] kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"} - got = compute_cross_design(samples, 100.4, tilted=True, tilt_pct=4.0, **kwargs) - assert got["ditch_enabled"] is False and got["cross_slope_pct"] == 4.0 + got = compute_cross_design(samples, 100.4, tilted=True, tilt_pct=None, **kwargs) + standard = compute_cross_design(samples, 100.4, **kwargs)["cross_slope_pct"] + assert got["ditch_enabled"] is False and got["cross_slope_pct"] == standard left, right = (got["road_edges"][k]["elevation_m"] for k in ("left", "right")) assert left > right # 지반이 좌로 오름 = 우가 낮음 @@ -131,6 +135,20 @@ def test_형상_옵셋선_성토_절토와_만남(ts): assert ts["drew"] is True and ts["stones"] > 0 +def test_하단_경계는_암거_경사(ts): + """371-3 — 노면 5 % · 암거 3 % · 높이 1 m: 하단 = 좌(높은 쪽) 끝 99.15 에서 3 % 로 우로 내림.""" + outline = [(p["offset"], p["elevation"]) for p in ts["sloped"]] + expected = [ + (3, 100.15), + (-3, 99.85), + (-4.391304, 98.928261), # 우 성토 사면 ∩ 하단(99.15 − 0.03 × 7.391) + (1.872180, 99.116165), # 좌 절토 사면을 늘인 선 ∩ 하단 + ] + assert len(outline) == len(expected) + for got, want in zip(outline, expected): + assert got == pytest.approx(want, abs=1e-5) + + def test_양측절토_늘인_노면_원지반_교점에서_끊음(ts): """356-1 — 낮은 쪽 노면 끝도 절토(tilt_reach) = reach 에서 수직으로 끊음 · 너머 원지반 따라가지 않음.""" outline = [(p["offset"], p["elevation"]) for p in ts["bothCut"]] diff --git a/resources/tester/test_349_0n_cross_enforce_mirror.py b/resources/tester/test_349_0n_cross_enforce_mirror.py index ddf794d18..b692157b6 100644 --- a/resources/tester/test_349_0n_cross_enforce_mirror.py +++ b/resources/tester/test_349_0n_cross_enforce_mirror.py @@ -221,6 +221,9 @@ def test_포장_기울임_하강_판정_서버와_같음(r, server): hits["tilted"] += py["tilted"] hits["drop"] += py["drop"] > 0 assert all(hits.values()), hits # 셋 다 실제로 걸림 + # 371-3 — 맹암거 자리 = 기울임 · 경사 비움(노면 표준 · 암거 경사는 형상 하단 경계) + drain = [py for ts_at, py in zip(r["ts"]["at"], r["rules"]) if ts_at["chainage"] == 160.0] + assert drain == [{"paved": False, "tilted": True, "tilt_pct": None, "drop": 0.0}] KEYS = (