From 8823499e696251d1101cb48a8b3f3d9acc63283a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 15:40:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(section):=20=EC=9B=90=EC=A7=80=EB=B0=98=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20=ED=92=80=EA=B8=B0=20=E2=80=94=20=ED=9D=99?= =?UTF-8?q?=EB=A7=89=EC=9D=B4=20=EC=9E=90=EB=A6=AC=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EB=8B=A8=20=C2=B7=20=EB=86=92=EC=9D=B4=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C(54-45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관 유출 흙막이 · 독립 기슭막이 매몰 자리 되돌림 삭제(placePipeWall requireCrossing) - 유입 흙막이 유출 수용 가드 삭제(outletReceivable · placeInletWall) - 사면안정 흙막이 · 옹벽 매몰 자리 되돌림 삭제 - 추가 흙막이 매몰 자리 되돌림 · 다단 끊기(시작점 매몰 · 성토 0.7m 미만 · 앉을 지형 없음) 삭제 - 남김: 벽 윗면 노면 위 금지 · 관 m 단위 길이 - 시험 test_b06_ground_unlock 추가(화면 계산 · 서버 번들 같은 판) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../B06_Section_UI_Cross_Culvert_Extra.ts | 55 +------- .../B06_Section_UI_Cross_Culvert_Geom.ts | 33 +---- .../B06_Section_UI_Cross_Culvert_Solve.ts | 118 +----------------- B06_Section/B06_Section_UI_Cross_Revetment.ts | 33 +---- resources/tester/test_b06_ground_unlock.py | 101 +++++++++++++++ 5 files changed, 116 insertions(+), 224 deletions(-) create mode 100644 resources/tester/test_b06_ground_unlock.py diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index f3a90ef8b..5f29d0e79 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -238,10 +238,8 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult 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; + // 원지반은 단을 끊지 않는다 — 요청한 단 수 그대로 세운다(54-45). const trailing = trailingFill(src, outward, groundAt); - if (trailing.lengthM < 0.7) break; const adjust = input.adjusts[i]; // 형태(B05 폼 6종)가 재질·높이 한계를 정한다 — 조작이 없으면 소유 벽 형태를 따른다 @@ -293,8 +291,8 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult bestOffset = x; } } - if (autoOffset === null) autoOffset = bestOffset; - if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만. + // 앉을 지형이 없으면(묻힘) 이음선이 시작점 바로 밖 — 원지반과 무관하게 선다. + autoOffset ??= bestOffset ?? src.offset + outward * jointRunOf(exposed, lean); const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed, lean); /** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */ const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN; @@ -314,52 +312,11 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult FILL_SLOPE_RATIO_MIN : (plan[i] ?? 0); const requestedX = plan === "user" ? adjust.x : 0; - /** 후보 자리의 바닥 — buildExtraWall과 같은 근입 0.5m 고정(2026-08-23). */ - const bottomAt = (_anchorX: number, baseElevation: number): number => - baseElevation - REVET_EMBED_DEPTH_M; - // 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이 - // 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이 - // 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리). - const placeable = (xShift: number, vShift: number): boolean => { - const topE = autoTop - vShift; - const aX = autoOffset + outward * xShift; - const jointX = aX - outward * jointRunOf(exposed, lean); - if ( - Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >= - topE - 0.01 - ) - return false; - const baseE = topE - exposed; - const bottom = bottomAt(aX, baseE); - const startE = bottom + REVET_EMBED_DEPTH_M; - const topFrontX = jointX + outward * REVET_THICKNESS_M; - const startX = topFrontX + outward * lean * (topE - startE); - if (groundAt(startX) < startE + 0.05) return true; - // 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지. - return ( - slopedCrossing( - { offset: startX, elevation: startE }, - outward, - -PIPE_CONNECT_GRADE, - groundAt, - limitOffset, - ) !== null - ); - }; let appliedD = Math.max(requestedD, minV); /** 선반 길이가 음수가 되면 각도가 깨진다 — x가 최소 1.2·d까지 따라 나간다. */ const shelfFloor = (v: number): number => FILL_SLOPE_RATIO_MIN * v; + // 원지반은 자리를 막지 않는다 — 묻히든 뜨든 입력 그대로(54-45). let appliedX = Math.max(requestedX, 0, shelfFloor(appliedD)); - for (let pass = 0; pass < 400 && !placeable(appliedX, appliedD); pass += 1) { - // 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우). - if (appliedD > minV + 1e-9) { - appliedD = Math.max(minV, appliedD - 0.05); - appliedX = Math.max(appliedX, shelfFloor(appliedD)); - } else if (appliedX > shelfFloor(appliedD) + 1e-9) { - appliedX = Math.max(shelfFloor(appliedD), appliedX - 0.05); - } else break; - } - if (!placeable(appliedX, appliedD)) break; // 자동 자리조차 매몰 — 이 단은 불가. appliedD = Math.round(appliedD * 10) / 10; appliedX = Math.round(appliedX * 10) / 10; const topElevation = autoTop - appliedD; @@ -378,10 +335,6 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult floatGapM: Math.max(0, base - groundBase), lean, }); - // 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을 - // 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동). - const blocked = Math.max(0, (requestedX ?? 0) - appliedX); - if (blocked > 0.005) wall.shiftBlockedM = Math.round(blocked * 100) / 100; // 하한에 눌려 요청이 통째로 무시된 경우 — 지형이 아니라 **선반 길이**가 원인이다 // (2026-09-07 실측: d 2.6m 이면 x 하한이 3.12m 라 1.0m 요청이 아무 변화도 못 냄). // 사용자가 실제로 넣었을 때만 알린다 — 요청이 없으면(0) 하한은 그냥 기본 자리라 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index dba881258..f97453203 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -62,7 +62,6 @@ import { designInterpolator, slopeToeOffset, pipeAxisSolver, - placeInletWall, placePipeWall, slopeLengthAlong, } from "./B06_Section_UI_Cross_Culvert_Solve"; @@ -200,9 +199,8 @@ export function computeCulvertLayout( if (!basinReason && designAt) { const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); - // 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라 - // 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard). - const placed = placeInletWall({ + // 4축 배치는 공용 풀이(placePipeWall — Solve). 원지반은 자리를 막지 않는다(54-45). + const placed = placePipeWall({ autoOffset: slopeZeroAnchor( inletInfo.edge, inletInfo.outward, @@ -213,30 +211,10 @@ export function computeCulvertLayout( height: inletWallSpec.height, baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height, edgeElevation: inletInfo.edge.elevation_m, - // 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게 - // 오르내린다(관이 없어 유출 수용 검사 outletGuard도 뺀다 — 위 이동을 막던 원인). + // 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게 오르내린다. invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : invertCap(inletInfo.edge), adjust: adjInlet, defaultD: PIPE_WALL_DEFAULT_RUN_M, - groundAt, - limitOffset: inletInfo.limit, - // 독립 기슭막이는 유출 벽과 같은 자리 규칙 — 매몰-무교차 자리 금지(2026-08-29). - requireCrossing: culvert.hidden_pipe === true, - outletGuard: culvert.hidden_pipe - ? undefined - : { - edge: outletInfo.edge, - outward: outletInfo.outward, - height: outletWallSpec.height, - toeOffset: slopeToeOffset( - designAt, - groundAt, - outletInfo.edge.offset_m, - outletInfo.limit, - ), - limitOffset: outletInfo.limit, - lean: outletWallSpec.lean, - }, }); appliedAdjust.inlet.x = placed.x; appliedAdjust.inlet.d = placed.d; @@ -412,7 +390,7 @@ export function computeCulvertLayout( ? outletZeroInvert - ((offset - outletZeroAnchor) * outletInfo.outward) / FILL_SLOPE_RATIO_MIN : invertAt(offset); if (designAt) { - // 4축 배치(유입과 동일 풀이) + 유출 전용: 매몰-무교차 자리 금지(2026-08-22 ③). + // 4축 배치(유입과 동일 풀이). const placed = placePipeWall({ autoOffset: outletZeroAnchor, outward: outletInfo.outward, @@ -423,9 +401,6 @@ export function computeCulvertLayout( invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : inlet.elevation, adjust: adjOutlet, defaultD: PIPE_WALL_DEFAULT_RUN_M, - groundAt, - limitOffset: outletInfo.limit, - requireCrossing: true, }); appliedAdjust.outlet.x = placed.x; appliedAdjust.outlet.d = placed.d; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 964cc7a34..6bc79e34b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -8,7 +8,6 @@ import type { SectionSample } from "./B06_Section_Api_Fetch"; import { FILL_MIN_RISE_M, FILL_SLOPE_MAX_LENGTH_M, - PIPE_CONNECT_GRADE, FILL_SLOPE_RATIO_MIN, REVET_HEIGHT_STEP_M, REVET_LEAN_RATIO, @@ -21,7 +20,6 @@ import type { PipeEnd, WallAdjust, } from "./B06_Section_UI_Cross_Culvert_Types"; -import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; /** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. * `lean` = 그 벽의 전면 기울기(표준경사 · `revetWallSpec().lean`). */ @@ -370,8 +368,7 @@ export interface PipeWallPlacement { * 지점**(벽 상단 = 노견)의 자리·invert. d는 그 점 기준 절대값(null = 기본 * defaultD, 경사길이 4m 지점), 위 한계 = 성토선 0 지점(d ≥ 0)·invert 상한. * · x = 노견 연장 평행이동(안쪽 금지). - * · requireCrossing(유출): 매몰인데 0도 절토선이 원지반과 다시 못 만나는 자리는 - * 금지 — 위쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우). + * · 원지반은 자리를 막지 않는다 — 묻히든 뜨든 입력 그대로(54-45). */ export function placePipeWall(input: { /** 성토선 0 지점(벽 상단 = 노견)의 벽 자리(하단선 중점 offset). */ @@ -386,37 +383,14 @@ export function placePipeWall(input: { adjust: WallAdjust; /** d 미지정(null) 시의 기본 자리 — 성토사면 경사길이 4m 지점의 수평 성분. */ defaultD: number; - 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 - top0), -FILL_SLOPE_RATIO_MIN * (input.invertCap - (top0 - input.height)), ); - let d = Math.max(input.adjust.d ?? input.defaultD, 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 || - slopedCrossing( - { offset: anchorOffset, elevation: invertE }, - input.outward, - -PIPE_CONNECT_GRADE, - 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 d = Math.max(input.adjust.d ?? input.defaultD, Math.ceil(dLow * 10) / 10); + const x = Math.max(input.adjust.x, 0); const topElevation = top0 - d / FILL_SLOPE_RATIO_MIN; return { x, @@ -427,92 +401,6 @@ export function placePipeWall(input: { }; } -/** - * 유입 invert를 그 높이로 내렸을 때 **유출 벽이 성립하는가**(2026-08-22 사용자 확정). - * 유입이 유출보다 높아야 하므로 유입을 내리면 유출도 따라 내려간다 — 그런데 유출이 - * 원지반에 묻히고 3도 절토선이 지반과 다시 만나지 못하면 그 자리는 둘 수 없다. - * 이 경우 **유입도 더 내려가면 안 된다**(종전에는 유입만 내려가 유출이 깨졌다). - */ -export function outletReceivable(input: { - inletElevation: number; - edge: { offset_m: number; elevation_m: number }; - outward: number; - height: number; - toeOffset: number; - limitOffset: number; - groundAt: (offset: number) => number; - /** 유출 벽 전면 기울기(표준경사). */ - lean?: number; -}): boolean { - const invertAt = (offset: number): number => - Math.min(input.groundAt(offset), input.inletElevation); - const auto = - minShoulderWallOffset( - input.edge, - input.outward, - input.height, - invertAt, - input.toeOffset, - input.limitOffset, - input.lean, - ) ?? input.toeOffset; - const invert = invertAt(auto); - if (invert >= input.groundAt(auto) - 0.01) return true; - return ( - slopedCrossing( - { offset: auto, elevation: invert }, - input.outward, - -PIPE_CONNECT_GRADE, - input.groundAt, - input.limitOffset, - ) !== null - ); -} - -/** - * 유입 기슭막이 4축 배치 — placePipeWall에 **유출 수용 가능성 가드**를 얹은 것. - * 유입을 내리면 유출도 따라 내려가므로(유입 > 유출 조건), 유출이 못 받는 높이면 - * 유입도 그만큼 못 내려간다(2026-08-22 사용자 확정). - */ -export function placeInletWall(input: { - autoOffset: number; - outward: number; - height: number; - baseElevation0: number; - edgeElevation: number; - invertCap: number; - adjust: WallAdjust; - defaultD: number; - groundAt: (offset: number) => number; - limitOffset: number; - /** 유출 쪽 수용 검사 인자 — 없으면 가드 없이 배치한다. */ - outletGuard?: { - edge: { offset_m: number; elevation_m: number }; - outward: number; - height: number; - toeOffset: number; - limitOffset: number; - lean?: number; - }; - /** 매몰-무교차 자리 금지(유출 벽 규칙) — 독립 기슭막이는 유입도 같은 규칙을 탄다. */ - requireCrossing?: boolean; -}): PipeWallPlacement { - const place = (adjust: WallAdjust): PipeWallPlacement => - placePipeWall({ ...input, adjust, requireCrossing: input.requireCrossing === true }); - let placed = place(input.adjust); - const guard = input.outletGuard; - if (!guard) return placed; - const receivable = (invert: number): boolean => - outletReceivable({ inletElevation: invert, groundAt: input.groundAt, ...guard }); - for (let pass = 0; pass < 400 && placed.d > 1e-9 && !receivable(placed.invert); pass += 1) { - placed = place({ - ...input.adjust, - d: Math.max(0, Math.round((placed.d - 0.05) * 100) / 100), - }); - } - return placed; -} - /** 관 축·법선·양 끝단 꼭짓점을 한 번에 푸는 도구(본체 700줄 제한 — 2026-08-22). */ export interface PipeAxisSolver { axis: OffsetPoint; diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 0ff31a13e..f64c64493 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -20,7 +20,6 @@ import { showToast } from "@ui/ui_template_elements"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { FILL_SLOPE_RATIO_MIN, - PIPE_CONNECT_GRADE, PIPE_WALL_DEFAULT_RUN_M, REVET_EMBED_DEPTH_M, REVET_THICKNESS_M, @@ -28,7 +27,7 @@ import { import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; -import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; import { ZERO_ADJUST, type CulvertDesignTrim, @@ -206,36 +205,12 @@ export function computeRevetmentLayout( * (확장량 t는 아래 폴리라인에서 역산 — t ≥ 0이 각도 유지의 한계다). */ const defaultDrop = autoRun / FILL_SLOPE_RATIO_MIN; - /** 그 자리에 벽을 세울 수 있나 — 매몰(원지반 아래 daylight 없음) 금지. */ - const placeable = (xShift: number, drop: number): boolean => { - const anchor = autoOffset + outward * (autoRun + xShift); - const invert = edge.elevation_m - drop - height; - return ( - invert >= groundAt(anchor) - 0.01 || - slopedCrossing( - { offset: anchor, elevation: invert }, - outward, - -PIPE_CONNECT_GRADE, - groundAt, - limit, - ) !== null - ); - }; const requestedX = Math.max(adjust?.x ?? 0, 0); /** 각도(1:1.2)를 지키려면 그만큼은 밖으로 나가 있어야 하는 최소 수평 자리. */ const xFloor = (drop: number): number => Math.max(0, FILL_SLOPE_RATIO_MIN * drop - autoRun); - let appliedDrop = Math.max(adjust?.d ?? defaultDrop, 0); - let appliedX = Math.max(requestedX, xFloor(appliedDrop)); - for (let pass = 0; pass < 400 && !placeable(appliedX, appliedDrop); pass += 1) { - // 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우). - if (appliedDrop > 1e-9) { - appliedDrop = Math.max(0, appliedDrop - 0.05); - appliedX = Math.max(Math.min(appliedX, requestedX), xFloor(appliedDrop)); - } else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05); - else break; - } - appliedX = Math.round(appliedX * 10) / 10; - appliedDrop = Math.round(appliedDrop * 10) / 10; + // 원지반은 자리를 막지 않는다 — 묻히든 뜨든 입력 그대로(54-45). 노면 위 금지 = d ≥ 0. + const appliedDrop = Math.round(Math.max(adjust?.d ?? defaultDrop, 0) * 10) / 10; + const appliedX = Math.round(Math.max(requestedX, xFloor(appliedDrop)) * 10) / 10; const anchorOffset = autoOffset + outward * (autoRun + appliedX); const baseElevation = edge.elevation_m - appliedDrop - height; diff --git a/resources/tester/test_b06_ground_unlock.py b/resources/tester/test_b06_ground_unlock.py new file mode 100644 index 000000000..7cd714e7f --- /dev/null +++ b/resources/tester/test_b06_ground_unlock.py @@ -0,0 +1,101 @@ +"""원지반 잠금 풀기(54-45) — 흙막이 자리 · 다단 · 높이를 원지반이 막지 않음. + +남는 규칙: 벽 윗면 노면 위 금지(d ≥ 0) · 관 보호공 흙막이 m 단위 자리(관 길이 정수). +서버 면적은 같은 TS 를 Node 로 돌림(`B06_Section_Server_Calc_Node.ts`) — 번들도 같은 판인지 봄. +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +B06 = PROJECT_ROOT / "B06_Section" +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +BUNDLE = PROJECT_ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import { placePipeWall } from "./B06_Section_UI_Cross_Culvert_Solve.js"; +import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra.js"; +const pipe = (d, x) => placePipeWall({ + autoOffset: 10, outward: 1, height: 2, baseElevation0: 98, edgeElevation: 100, + invertCap: Number.POSITIVE_INFINITY, adjust: { x, d, h: null, m: null }, defaultD: 4.8, +}); +const extras = (ground, d) => buildOutletExtras({ + start: { offset: 10, elevation: 95 }, startBottomElevation: 94.5, outward: 1, + groundAt: () => ground, limitOffset: 40, + adjusts: [{ x: 0, d, h: 2, m: null }, { x: 0, d, h: 2, m: null }], +}); +const buried = extras(100, 1); +const high = extras(80, 3); +writeFileSync(process.argv[2], JSON.stringify({ + deep: pipe(6, 2), up: pipe(-5, 0), + buriedCount: buried.walls.length, buriedD: buried.appliedAdjusts.map((a) => a.d), + highCount: high.walls.length, highD: high.appliedAdjusts.map((a) => a.d), +})); +""" + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_원지반이_자리_다단을_막지_않고_노면_위만_막음(tmp_path: Path) -> None: + out = tmp_path / "js" + for name in ("Culvert_Solve", "Culvert_Extra"): + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(B06 / f"B06_Section_UI_Cross_{name}.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--skipLibCheck", + "--ignoreConfig", + ], + cwd=str(PROJECT_ROOT), + capture_output=True, + ) + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + base = next(path.parent for path in out.rglob("B06_Section_UI_Cross_Culvert_Solve.js")) + (base / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(base / "runner.mjs"), str(result)], check=True, capture_output=True + ) + got = json.loads(result.read_text(encoding="utf-8")) + + assert (got["deep"]["d"], got["deep"]["x"]) == (6, 2), "관 흙막이 깊이 · 좌우가 되돌아감" + assert got["up"]["d"] == pytest.approx(0), "관 흙막이 윗면이 노면 위로 올라감" + assert got["up"]["topElevation"] == pytest.approx(100), "노면 멈춤 자리 틀림" + assert got["buriedCount"] == 2, "원지반에 묻힌 다단이 끊김" + assert got["buriedD"] == [1, 1], "묻힌 다단의 높이가 되돌아감" + assert got["highCount"] == 2, "원지반 위 다단이 끊김" + assert got["highD"] == [3, 3], "뜬 다단의 높이가 되돌아감" + + +def test_화면_서버_같은_판() -> None: + for name in ("Culvert_Solve", "Culvert_Geom", "Revetment"): + source = (B06 / f"B06_Section_UI_Cross_{name}.ts").read_text(encoding="utf-8") + assert "requireCrossing" not in source, f"{name} 에 매몰 자리 금지가 남음" + assert "outletReceivable" not in source, f"{name} 에 유출 수용 가드가 남음" + assert "const placeable" not in (B06 / "B06_Section_UI_Cross_Revetment.ts").read_text( + encoding="utf-8" + ), "사면안정 흙막이 · 옹벽 매몰 자리 금지가 남음" + assert "const placeable" not in (B06 / "B06_Section_UI_Cross_Culvert_Extra.ts").read_text( + encoding="utf-8" + ), "추가 흙막이 매몰 자리 금지가 남음" + bundle = BUNDLE.read_text(encoding="utf-8") + assert "outletReceivable" not in bundle and "requireCrossing" not in bundle, ( + "서버 번들이 옛 판 — npm run build:server-calc" + )