From f54def5b2b72e1949d54796e331c94bc26d97d33 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:05:09 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90=20?= =?UTF-8?q?=C2=B7=20=EB=B0=95=EC=8A=A4=20=EC=9C=A0=EC=9E=85=20=EC=88=98?= =?UTF-8?q?=ED=8F=89=EA=B3=B5=EA=B0=84=20=EC=8B=9C=EC=9E=91=20=3D=20?= =?UTF-8?q?=EB=B3=B8=EC=B2=B4=20=EB=B0=94=EB=8B=A5=20=EB=86=92=EC=9D=B4=20?= =?UTF-8?q?=ED=9D=99=EB=A7=89=EC=9D=B4=20=EC=A0=84=EB=A9=B4(89-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B06_Section/B06_Section_UI_Cross_Body_Guard.ts | 17 +++++++++++++++++ .../B06_Section_UI_Cross_Culvert_Geom.ts | 3 ++- resources/tester/helper_79_guard_shape.cjs | 13 +++++++++++++ resources/tester/test_79_guard_shape.py | 6 ++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index 640af0277..b3fdefb2b 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -271,3 +271,20 @@ export function withoutGuardedFill( label: { ...ford.label, text: `${ford.label.text} L${Math.ceil(lengthM - 1e-6)}m` }, } as T; } + +/** 세월교 · 박스 유입 흙막이 수평공간 시작 = 본체 바닥(세월교 바닥판 · 박스 저판 상면) 높이의 + * 흙막이 전면 자리(89-3). 본체가 이 측점에 없으면 null(배관 규칙). */ +export function guardFloorStart( + section: CrossSection, + base: BodyGuardBase | null, + wall: WallLayout, +): OffsetPoint | null { + const end = base?.ends.inlet; + if (!end) return null; + const slab = section.culvert?.body === "box" ? (section.box?.slab_thickness_m ?? 0) : 0; + const floor = end.invert + slab; + return { + offset: wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - floor), + elevation: floor, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 7c3b5bbd9..14c2cecde 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -25,6 +25,7 @@ import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall"; import { bodyGuardAxes, bodyGuardBase, + guardFloorStart, bodyGuardHeight, fordPipeFit, mergeGuardTrim, @@ -596,7 +597,7 @@ export function computeCulvertLayout( outletBottom: outletWall?.bottomBack.elevation ?? 0, basinStart: culvert.hidden_pipe ? inletWall - ? pipeCorners.inlet.bottom + ? (guardFloorStart(section, body, inletWall) ?? pipeCorners.inlet.bottom) : null : basinFillStart, basinBottom: culvert.hidden_pipe ? (inletWall?.bottomBack.elevation ?? 0) : basinFillBottom, diff --git a/resources/tester/helper_79_guard_shape.cjs b/resources/tester/helper_79_guard_shape.cjs index 69a30ad20..aacab3ac3 100644 --- a/resources/tester/helper_79_guard_shape.cjs +++ b/resources/tester/helper_79_guard_shape.cjs @@ -154,6 +154,19 @@ out.cutBerm2 = cutTails( sections.map((s) => (s === at(352.14) ? berm2 : s)), ); +// ⑤b 유입 수평공간 시작 높이 = 본체 바닥(세월교 바닥판 상면 · 박스 저판 상면 · 89-3) +out.inletFloor = [352.14, 173.087].map((chainage) => { + const section = at(chainage); + const layouts = computeStoredLayouts(section, sections); + const start = layouts.culvert.basinFill.segments[0]?.points[0]; + const inletLeft = (section.uphill_side ?? "left") === "left"; + const floor = layouts.ford + ? layouts.ford.sides.find((s) => s.role === "inlet").slabTopElevation + : layouts.box.sides.find((s) => (s.role === "left") === inletLeft).bottomElevation + + layouts.box.box.slab_thickness_m; + return { start: start ? r3(start.elevation) : null, floor: r3(floor) }; +}); + // ⑥ 관 보호공 기본 자리 = 제일 위(성토선 0 지점 · d 0) · 관 길이 m 단위 const pipe = clone(at(180)); pipe.culvert = { diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index f33cbcafe..f12a573dc 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -87,6 +87,12 @@ def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None: assert tail["berm"] == pytest.approx(2.0, abs=1e-3) +def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None: + """89-3 — 박스 저판 상면 · 세월교 바닥판 상면 높이에서 수평 → 절토.""" + for case in shape["inletFloor"]: + assert case["start"] == pytest.approx(case["floor"], abs=1e-3) + + def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None: pipe = shape["pipe"] assert pipe is not None From d9bd08a236545ac94f0a5034ba4d034442c1f0f8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:07:48 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90=20?= =?UTF-8?q?=C2=B7=20=EB=B0=95=EC=8A=A4=20=EC=9C=A0=EC=B6=9C=20=EC=88=98?= =?UTF-8?q?=ED=8F=89=EA=B3=B5=EA=B0=84=20=EC=97=86=EC=95=B0=20=E2=80=94=20?= =?UTF-8?q?=EC=B9=B8=20=C2=B7=20=EA=B8=B0=ED=95=98=20=C2=B7=20=EC=84=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=A7=9D=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EC=9D=B4=EB=A6=84=20=EC=A4=84(89-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- A00_Common/design/design_structure_guard.ts | 3 ++- .../B05_Profile_UI_Drainage_Facility_Merge.ts | 4 ++-- B06_Section/B06_Section_Engine_Culvert.py | 4 ++-- B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts | 13 +++++-------- common_util/common_util_culvert_sets.ts | 5 +++-- resources/master_template/구조물정의.json | 2 -- resources/tester/helper_79_guard_shape.cjs | 9 +++++++-- resources/tester/test_65_ford_defaults.py | 5 +++-- resources/tester/test_79_guard_shape.py | 6 ++++-- 9 files changed, 28 insertions(+), 23 deletions(-) diff --git a/A00_Common/design/design_structure_guard.ts b/A00_Common/design/design_structure_guard.ts index e1b4fbdb5..3d4ad1c45 100644 --- a/A00_Common/design/design_structure_guard.ts +++ b/A00_Common/design/design_structure_guard.ts @@ -83,11 +83,12 @@ export const revetKeys = (side: "inlet" | "outlet"): Record => ( }); /** 세월교 · 박스 유입 · 유출 흙막이 칸(79-7) — 높이 빈 칸 = 자동(`heightHint` · 횡단도가 셈) · - * 절토부 앞 수평공간 칸(`{side}_revet_berm_m`)을 더함. */ + * 유입만 절토부 앞 수평공간 칸(`inlet_revet_berm_m` · 유출은 없음 · 89-4)을 더함. */ export function bodyGuardItems(side: "inlet" | "outlet", heightHint: string): SpecItem[] { const items = guardItems({ 흙막이: revetKeys(side) }, undefined, true).map((item) => item.id === "height" ? { ...item, default: undefined, hint: heightHint } : item, ); + if (side === "outlet") return items; const berm: SpecItem = { id: "berm", label: "수평공간 (m)", diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 0bab3c485..49dc35c2e 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -43,7 +43,7 @@ export const FORM_OPTION_KEYS: Readonly> = { ...revetKeys("inlet"), ...revetKeys("outlet"), "inlet_revet_berm_m", // 흙막이 절토부 앞 수평공간(79-7) - "outlet_revet_berm_m", + "outlet_revet_berm_m", // 칸 없앰(89-4) — 목록에 남겨 다음 [저장] 때 옛 값을 지움 ], // 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) + 월류 폭 · 높이(65-5) — 옛 경사 · // 두께 · 길이는 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES` @@ -61,7 +61,7 @@ export const FORM_OPTION_KEYS: Readonly> = { ...revetKeys("inlet"), ...revetKeys("outlet"), "inlet_revet_berm_m", - "outlet_revet_berm_m", + "outlet_revet_berm_m", // 칸 없앰(89-4) — 목록에 남겨 다음 [저장] 때 옛 값을 지움 ], revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")], }; diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 9f0631eec..ab28ec192 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -329,8 +329,8 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str for role, key in zip(("inlet", "outlet"), _GUARD_KEYS[body]): guarded = str(values.get(key) or "").strip() == "흙막이" sides[role] = _revet_side(values, role) if guarded else {"role": role, "structure": ""} - if guarded: - # 절토부 앞 수평공간(79-7) — 흙막이 컨테이너 칸 · 빈 값 = 화면 기본값 + if guarded and role == "inlet": + # 절토부 앞 수평공간(79-7) — 유입만(유출은 없음 · 89-4) · 빈 값 = 화면 기본값 sides[role]["revet_berm_m"] = _number(values.get(f"{role}_revet_berm_m"), None) if not any(side["structure"] for side in sides.values()): return None diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts index 6396cc06f..4978284d2 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts @@ -5,7 +5,7 @@ * 집수정 계류측 두 채널의 입력을 짜고 면적용(미확정 단 제외) 결과를 함께 돌려준다. * ========================================================================== */ -import type { CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; import { GUARD_CUT_BERM_M, REVET_EMBED_DEPTH_M, @@ -85,17 +85,14 @@ export function buildCulvertTiers(input: { side: culvert?.hidden_pipe ? culvert.inlet.face_side : null, }); // 세월교 · 박스 흙막이 끝 절토 = 컨테이너 수평공간 뒤 절토 경사(79-7) · 배관은 종전(3도 선). - const cutBerm = (spec: CulvertSideSpec | undefined) => + const cutBerm = (lengthM: number) => culvert?.body - ? { - lengthM: Math.max(spec?.revet_berm_m ?? GUARD_CUT_BERM_M, 0), - slopeRatio: section.design?.cut_slope_ratio ?? 1.0, - } + ? { lengthM: Math.max(lengthM, 0), slopeRatio: section.design?.cut_slope_ratio ?? 1.0 } : undefined; // 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로). // 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다. const outletInput = { - cutBerm: cutBerm(culvert?.outlet), + cutBerm: cutBerm(0), // 유출 수평공간 없음 — 끝이 원지반에 묻히면 바로 절토(89-4) startBottomElevation: input.outletBottom, outward: input.outletInfo.outward, groundAt, @@ -109,7 +106,7 @@ export function buildCulvertTiers(input: { // 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 — // 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자). const basinInput = { - cutBerm: cutBerm(culvert?.inlet), + cutBerm: cutBerm(culvert?.inlet.revet_berm_m ?? GUARD_CUT_BERM_M), startBottomElevation: input.basinBottom, outward: input.inletInfo.outward, groundAt, diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 235142da7..490e7d4c6 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -213,12 +213,13 @@ export function guardSet( diameterM: number, ): CulvertSetSpec | null { const [inletKey, outletKey] = GUARD_KEYS[body]; - // 절토부 앞 수평공간(79-7) — 흙막이 컨테이너 칸 · 빈 값 = 화면 기본값 const side = (role: string, key: string): CulvertSetSpec => String(values[key] ?? "").trim() === "흙막이" - ? { ...revetSide(values, role), revet_berm_m: num(values[`${role}_revet_berm_m`], null) } + ? revetSide(values, role) : { role, structure: "" }; const inlet = side("inlet", inletKey); + // 절토부 앞 수평공간(79-7) — 유입만(유출은 없음 · 89-4) · 빈 값 = 화면 기본값 + if (inlet.structure) inlet.revet_berm_m = num(values.inlet_revet_berm_m, null); const outlet = side("outlet", outletKey); if (!inlet.structure && !outlet.structure) return null; return { diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 32a69a793..308e34b20 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3418,7 +3418,6 @@ "outlet.height": "높이 (m)", "outlet.before": "기준측점 전 (m)", "outlet.after": "기준측점 후 (m)", - "outlet.berm": "수평공간 (m)", "soil_add": "흙막이", "soil_add.form": "형태", "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", @@ -3489,7 +3488,6 @@ "outlet.height": "높이 (m)", "outlet.before": "기준측점 전 (m)", "outlet.after": "기준측점 후 (m)", - "outlet.berm": "수평공간 (m)", "soil_add": "흙막이", "soil_add.form": "형태", "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", diff --git a/resources/tester/helper_79_guard_shape.cjs b/resources/tester/helper_79_guard_shape.cjs index aacab3ac3..4fa0852ee 100644 --- a/resources/tester/helper_79_guard_shape.cjs +++ b/resources/tester/helper_79_guard_shape.cjs @@ -137,9 +137,14 @@ out.boxWing = wingLayout.sides.map((s) => ({ // ⑤ 흙막이 끝 절토 = 수평공간 뒤 절토 경사 — 수평공간 칸 값을 따름 function cutTails(section, list) { const culvert = computeStoredLayouts(section, list).culvert; - return [...culvert.outletFill.segments, ...culvert.basinFill.segments] + const tagged = (segments, role) => segments.map((g) => ({ ...g, role })); + return [ + ...tagged(culvert.outletFill.segments, "outlet"), + ...tagged(culvert.basinFill.segments, "inlet"), + ] .filter((g) => g.kind === "cut") .map((g) => ({ + role: g.role, points: g.points.length, berm: r3(Math.abs(g.points[1].offset - g.points[0].offset)), flat: r3(g.points[1].elevation - g.points[0].elevation), @@ -148,7 +153,7 @@ function cutTails(section, list) { out.cutDefault = [cutTails(at(352.14), sections), cutTails(at(173.087), sections)]; const berm2 = clone(at(352.14)); berm2.culvert.inlet.revet_berm_m = 2; -berm2.culvert.outlet.revet_berm_m = 2; +berm2.culvert.outlet.revet_berm_m = 2; // 유출은 칸이 없어 무시(89-4) out.cutBerm2 = cutTails( berm2, sections.map((s) => (s === at(352.14) ? berm2 : s)), diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 052826723..863d2d2a4 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -35,7 +35,7 @@ GUARD = [ ["height", ["흙막이"], None], # 빈 칸 = 본체 끝 높이(79-7 · 횡단도가 셈) ["before", ["흙막이"], "5"], ["after", ["흙막이"], "5"], - ["berm", ["흙막이"], "1"], # 절토부 앞 수평공간(79-7) + ["berm", ["흙막이"], "1"], # 절토부 앞 수평공간(79-7) — 유입만(89-4) ] @@ -55,7 +55,8 @@ def out() -> dict: def test_유입_유출은_구조와_흙막이_칸만(out): for spec in ("ford", "box"): for side in ("inlet", "outlet"): - assert out[spec][side] == GUARD, (spec, side) + want = GUARD if side == "inlet" else GUARD[:-1] + assert out[spec][side] == want, (spec, side) assert [i[0] for i in out[spec]["soil_add"]] == [ "form", "length", diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index f12a573dc..9c8f0291d 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -79,12 +79,14 @@ def test_박스_날개벽_끝_절성토선(shape: dict) -> None: def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None: tails = [tail for group in shape["cutDefault"] for tail in group] assert tails, "절토 끝선이 없음" + assert any(tail["role"] == "inlet" for tail in tails) for tail in tails: assert tail["points"] == 3 assert tail["flat"] == pytest.approx(0, abs=1e-6) - assert tail["berm"] == pytest.approx(1.0, abs=1e-3) + # 유출은 수평공간 없음 — 묻히면 바로 절토(89-4) + assert tail["berm"] == pytest.approx(1.0 if tail["role"] == "inlet" else 0, abs=1e-3) for tail in shape["cutBerm2"]: - assert tail["berm"] == pytest.approx(2.0, abs=1e-3) + assert tail["berm"] == pytest.approx(2.0 if tail["role"] == "inlet" else 0, abs=1e-3) def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None: From 434a745ff68024e4f4151524e8edfd2c614621e4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:13:43 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(B05):=20=EC=84=B8=EC=9B=94=EA=B5=90=20?= =?UTF-8?q?=C2=B7=20=EB=B0=95=EC=8A=A4=20=EC=9C=A0=EC=B6=9C=20=3D=20?= =?UTF-8?q?=E3=80=8C=EC=B6=94=EA=B0=80=20=ED=9D=99=EB=A7=89=EC=9D=B4(?= =?UTF-8?q?=EB=8B=A8)=E3=80=8D=20=EA=B3=B5=EC=9A=A9=20=EC=8A=A4=ED=85=8C?= =?UTF-8?q?=ED=8D=BC=20=C2=B7=20=E3=80=8C+=20=ED=9D=99=EB=A7=89=EC=9D=B4?= =?UTF-8?q?=E3=80=8D=20=EC=97=86=EC=95=B0=20=C2=B7=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EB=8B=A8=20=EC=88=98=EB=A1=9C=20=EC=83=81=EC=9E=90(90-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../B05_Profile_UI_Drainage_Facility.ts | 47 ++++++++-------- .../B05_Profile_UI_Drainage_Revet_Choice.ts | 56 ++++++++++++++++++- B06_Section/B06_Section_UI_Adjust_Dock.ts | 9 +-- .../B06_Section_UI_Page_Pipe_Options.ts | 15 ----- M02_MasterTemplete/M02_Table_Fill_Spec.py | 11 +++- .../tester/helper_54_27_soil_tier_field.cjs | 50 ++++++++++++++--- resources/tester/test_54_10_soil_tiers.py | 2 + .../tester/test_54_27_soil_tier_field.py | 14 ++++- resources/tester/test_65_ford_defaults.py | 34 +++++++---- 9 files changed, 170 insertions(+), 68 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 54ee46eb3..b7592b7ef 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -40,13 +40,16 @@ import { INLET_REVET_KEYS, type InletStructureKind, labeled, - numberInput, OUTLET_REVET_KEYS, - stepper, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; -import { guardChoiceFields, revetChoiceRow } from "./B05_Profile_UI_Drainage_Revet_Choice"; +import { + guardChoiceFields, + guardSoilCount, + outletTiersRow, + revetChoiceRow, +} from "./B05_Profile_UI_Drainage_Revet_Choice"; import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { createSpecForm, @@ -67,7 +70,6 @@ import { pipeDrawn, pipeExtraCount, pipeLengthM, - setPipeExtraCount, } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, @@ -296,25 +298,11 @@ export function createFacilityOptionsForm( return drawnMemo; }; // 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감 - const tiersInput = numberInput("1", "0"); - tiersInput.max = "9"; - const tiersRow = labeled("추가 흙막이(단)", stepper(tiersInput, 1, 0)); - tiersRow.dataset.specItem = "outlet.tiers"; - tiersRow - .querySelectorAll("button") - .forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`)); - const paintTiers = (values: Record): HTMLElement => { - const count = pipeExtraCount(chainage); - tiersInput.value = count === null ? "" : String(count); - tiersInput.disabled = count === null; - tiersRow.hidden = !!values.structure && values.structure !== "기슭막이"; - return tiersRow; - }; - tiersInput.addEventListener("change", () => { - const count = Math.max(0, Math.min(9, Math.round(Number(tiersInput.value) || 0))); - // 지형이 못 세운 단은 횡단 기하가 자름 — 끝난 뒤 세운 만큼으로 다시 그림 - void setPipeExtraCount(chainage, count).then(() => pipeForm.recount()); - }); + const tiersRow = outletTiersRow( + () => chainage, + () => pipeForm.recount(), + (structure) => !structure || structure === "기슭막이", + ); const inletChoice = revetChoiceRow( () => chainage, () => pipeForm.refresh(), @@ -327,7 +315,7 @@ export function createFacilityOptionsForm( // 관 보호공 [연동] · [경사](54-30) — 유입 · 유출 상자 각각 한 줄(같은 값) · 길이 있는 흙막이만(54-40 · 집수정 · 안 고름은 숨김) fields: (c, values) => c.id === "outlet" - ? [paintTiers(values), outletChoice.paint(values.structure !== "기슭막이")] + ? [tiersRow.paint(values), outletChoice.paint(values.structure !== "기슭막이")] : c.id === "inlet" ? [inletChoice.paint(values.structure !== "기슭막이")] : c.id === "soil_add" // 추가 흙막이 상자도 같은 줄(54-36 · 상자마다 한 줄 · 같은 값) @@ -407,11 +395,17 @@ export function createFacilityOptionsForm( }; }; const fordFlowBox = overflow(() => fordForm, "ford"); + // 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1) const fordForm: SpecForm = createSpecForm(specLinks, { onChange: fordFlowBox.onChange, fields: guardChoiceFields( () => chainage, () => fordForm.refresh(), + () => fordForm.recount(), + ), + count: guardSoilCount( + () => chainage, + () => fordForm.state().outlet?.[0]?.structure, ), }); @@ -473,6 +467,11 @@ export function createFacilityOptionsForm( fields: guardChoiceFields( () => chainage, () => boxForm.refresh(), + () => boxForm.recount(), + ), + count: guardSoilCount( + () => chainage, + () => boxForm.state().outlet?.[0]?.structure, ), }); // 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts b/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts index f6a147ffc..93a98bb67 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts @@ -6,8 +6,11 @@ * ========================================================================== */ import { toggleButton } from "./B05_Profile_UI_Panel_Fields"; +import { labeled, numberInput, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { + pipeExtraCount, pipeRevetChoice, + setPipeExtraCount, setPipeRevetChoice, } from "../A00_Common/design/design_structure_pipe_calc"; @@ -54,20 +57,67 @@ export function revetChoiceRow( return { paint }; } -/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄. - * 명세 폼 `fields` 자리에 그대로 씀. */ +/** 유출 「추가 흙막이(단)」(54-27 · 90-1 배관 · 세월교 · 박스 공용) — 값 = 횡단 조정 단 수(같은 저장 자리) · + * 흙막이 상자 수가 따라감(폼 `count` 훅 = `pipeExtraCount`). `shown` = 유출 구조 값이 벽인지. */ +export function outletTiersRow( + chainageOf: () => number | null, + /** 쓴 뒤 — 지형이 못 세운 단은 횡단 기하가 자름 · 세운 만큼으로 상자를 다시 셈. */ + recount: () => void, + shown: (structure: string | undefined) => boolean, +): { paint: (values: Record) => HTMLElement } { + const input = numberInput("1", "0"); + input.max = "9"; + const row = labeled("추가 흙막이(단)", stepper(input, 1, 0)); + row.dataset.specItem = "outlet.tiers"; + row.querySelectorAll("button").forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`)); + input.addEventListener("change", () => { + const count = Math.max(0, Math.min(9, Math.round(Number(input.value) || 0))); + void setPipeExtraCount(chainageOf(), count).then(recount); + }); + return { + paint: (values) => { + const count = pipeExtraCount(chainageOf()); + input.value = count === null ? "" : String(count); + input.disabled = count === null; + row.hidden = !shown(values.structure); + return row; + }, + }; +} + +/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄 · + * 유출 상자엔 「추가 흙막이(단)」(90-1). 명세 폼 `fields` 자리에 그대로 씀. */ export function guardChoiceFields( chainageOf: () => number | null, written: () => void, + recount: () => void, ): (c: { id: string }, values: Record) => HTMLElement[] { const sides = { inlet: revetChoiceRow(chainageOf, written), outlet: revetChoiceRow(chainageOf, written), }; + const tiers = outletTiersRow(chainageOf, recount, (structure) => structure === "흙막이"); return (c, values) => c.id === "soil_add" ? [revetChoiceRow(chainageOf, written).paint(false)] : c.id === "inlet" || c.id === "outlet" - ? [sides[c.id].paint(values.structure !== "흙막이")] + ? [ + ...(c.id === "outlet" ? [tiers.paint(values)] : []), + sides[c.id].paint(values.structure !== "흙막이"), + ] : []; } + +/** 세월교 · 박스 흙막이 상자 수(90-1) — 유출이 흙막이면 지형이 세운 유출 단 수 · 아니면 0(옛 상자는 + * 숨기고 값은 파일에 남김). 명세 폼 `count` 훅 자리. */ +export function guardSoilCount( + chainageOf: () => number | null, + outletStructure: () => string | undefined, +): (c: { id: string }) => number | null | undefined { + return (c) => + c.id !== "soil_add" + ? undefined + : outletStructure() === "흙막이" + ? pipeExtraCount(chainageOf()) + : 0; +} diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index d1979183c..6c6b3aef8 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -175,10 +175,11 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { const group = target.closest("fieldset"); if (!group) return; const ownerSlot = slotOf(panelRoot.dataset.extraSide === "inlet" ? "inlet" : "outlet"); - // 배관 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10) — 옛 추가 기슭막이 칸 대신 그 상자를 켬 · - // 단 수 · 옵션은 부모(유출) 칸에 그대로 + // 배관 · 세월교 · 박스 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10 · 90-1) — 옛 추가 기슭막이 칸 + // 대신 그 상자를 켬 · 단 수 · 옵션은 부모(유출) 칸에 그대로 const soil = - ownerSlot === sideSlots.outlet && slotUsable(ownerSlot) + (ownerSlot === sideSlots.outlet || ownerSlot === sideSlots.wingOutlet) && + slotUsable(ownerSlot) ? ownerSlot .closest(".b05-structure__spec") ?.querySelectorAll('fieldset[data-spec-container="soil_add"]')[ @@ -186,7 +187,7 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { ] : undefined; if (soil) { - moveRows(panelRoot, PIPE_OUTLET_ROWS, ownerSlot); + moveRows(panelRoot, rowsFor(ownerSlot), ownerSlot); setGroupLabel(soil, ""); soil.classList.add("is-active"); if (scroll) soil.scrollIntoView({ block: "nearest" }); diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 96bba9554..90a9243c5 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -142,13 +142,6 @@ export function applyPipeOptionsToCache( }); } } - // 세월교 · 박스 추가 흙막이 = 폼 상자 수가 유출 다단 수(배관은 횡단이 정함 · 73-4) - const soil = body ? soilCount(patch) : null; - if (soil !== null && soil !== (owner.design?.extra_wall_counts?.outlet ?? 0)) - // 늦게 불러옴 — 흙막이 세트 때만 쓰는 횡단 기하 묶음(배관 폼 길은 안 끌어옴) - void import("../A00_Common/design/design_structure_pipe_calc").then((calc) => - calc.setPipeExtraCount(owner.chainage_m, soil), - ); if (!culvert.hidden_pipe || body) applySoilTiers(ctx, owner, patch, whole); own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m")); own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m")); @@ -190,14 +183,6 @@ function syncBodyGuard( return !!guard; } -/** 추가 흙막이 상자 수 — 가장 큰 `soil_add_{n}_*` 의 n. */ -function soilCount(patch: Record): number { - return Math.max( - 0, - ...Object.keys(patch).map((key) => Number(/^soil_add_(\d+)_/.exec(key)?.[1] ?? 0)), - ); -} - /** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에 * 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */ const soilSent = new Map(); diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py index 57a9df415..6e077e1d5 100644 --- a/M02_MasterTemplete/M02_Table_Fill_Spec.py +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -7,7 +7,8 @@ 짝: `A00_Common/design/design_structure_guard.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) — 시험이 대조. 기타(52-14) = 사용자가 적은 상세 종류 이름이 집계 건 이름(`그룹`) · 비면 「기타」. -배관 흙막이 = 지형이 세운 유출 다단 수(`흙막이단`)까지만 — 넘친 컨테이너 값은 파일에만(54-11). +배관 · 세월교 · 박스 흙막이 = 지형이 세운 유출 다단 수(`흙막이단`)까지만 — 넘친 컨테이너 값은 +파일에만(54-11 · 90-1) · 세월교 · 박스 유출이 흙막이가 아니면 0. """ from __future__ import annotations @@ -30,6 +31,9 @@ SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = { "etc": (("etc_detail", "getcdetail", "상세 내용"),), } _TEXT = ("structure", "form", "note") +#: 흙막이 상자 수 = 지형이 세운 유출 다단 수인 종류(54-11 · 90-1) · 세월교 · 박스 유출 구조 칸 +_TIERED = ("pipe", "box_culvert", "ford_bridge") +_BODY_OUTLET = {"box_culvert": "wing_out", "ford_bridge": "outlet_type"} def etc_label(type_id: str, options: dict[str, Any] | None) -> dict[str, str]: @@ -70,6 +74,9 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str """한 구조물의 선택 컨테이너 인스턴스마다 한 건(n 차례) — 컨테이너 없는 종류는 빈 목록.""" rows: list[dict[str, Any]] = [] tiers = item.get("흙막이단") # None = 모름(저장된 것 그대로) + guard_key = _BODY_OUTLET.get(item["type_id"]) + if guard_key and str(item["options"].get(guard_key) or "").strip() != "흙막이": + tiers = 0 # 유출이 날개벽 · 없음 — 옛 상자는 숨김 · 값은 파일에 남김(90-1) for prefix, link, title in SPEC_CONTAINERS.get(item["type_id"], ()): head = re.compile(rf"^{prefix}_(\d+)_(.+)$") found: dict[int, dict[str, Any]] = {} @@ -80,7 +87,7 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str for n in sorted(found): if ( prefix == "soil_add" - and item["type_id"] == "pipe" + and item["type_id"] in _TIERED and tiers is not None and n > tiers ): diff --git a/resources/tester/helper_54_27_soil_tier_field.cjs b/resources/tester/helper_54_27_soil_tier_field.cjs index 975e4948a..abee5d6a7 100644 --- a/resources/tester/helper_54_27_soil_tier_field.cjs +++ b/resources/tester/helper_54_27_soil_tier_field.cjs @@ -118,6 +118,8 @@ const PIPE_CALC = { pipeDrawn: () => null, pipeLengthM: () => null, pipeExtraCount: () => Math.min(stored, LIMIT), + pipeRevetChoice: () => null, + setPipeRevetChoice: () => Promise.resolve(), setPipeExtraCount: (chainage, count) => { writes.push([chainage, count]); stored = count; @@ -127,11 +129,8 @@ const PIPE_CALC = { const STUBS = { "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, "../A00_Common/design/design_structure_pipe_calc": PIPE_CALC, - // 관 보호공 [연동] · [경사] 줄(54-30) — 여기서는 자리만 - "./B05_Profile_UI_Drainage_Revet_Choice": { - revetChoiceRow: () => ({ paint: () => new FakeNode("div") }), - guardChoiceFields: () => () => [], - }, + // 관 보호공 [연동] · [경사] 단추(54-30) — 여기서는 자리만 · 단 칸(90-1 공용)은 실제 파일 + "./B05_Profile_UI_Panel_Fields": { toggleButton: () => new FakeNode("button") }, "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => { const root = new FakeNode("div"); @@ -169,7 +168,13 @@ const form = createFacilityOptionsForm(); form.setChainage(100); form.setFacility("pipe", { pipe_diameter_mm: 1000 }); -const spec = form.root.querySelector(".b05-structure__spec"); +// 시설마다 명세 폼이 따로 — 지금 시설의 폼(본체 상자 id 로 찾음) +let body = null; +const specOf = () => + form.root + .querySelectorAll(".b05-structure__spec") + .find((s) => !body || s.children.some((k) => k.dataset?.specContainer === body)); +let spec = specOf(); const boxesOf = (id) => spec.children.filter((k) => k.dataset?.specContainer === id); const tierRow = () => boxesOf("outlet")[0] @@ -199,10 +204,41 @@ async function main() { stored = 2; form.recount(); out.fromCross = snap(); - out.writes = writes; // 다른 구조(옛 값)면 칸을 가림 form.setFacility("pipe", { pipe_diameter_mm: 1000, outlet_type: "옛값" }); out.otherHidden = tierRow()?.hidden ?? null; + // 세월교 · 박스 유출 흙막이(90-1) — 같은 단 칸 · 단 수 = 흙막이 상자 수 · 「+ 흙막이」 없음 · + // 유출이 흙막이가 아니면 단 칸 · 옛 상자 숨김(값은 남김) + const addButton = () => + spec.children.find((k) => k.dataset?.specAdd === "soil_add") ? true : false; + const old = { soil_add_1_form: "바자", soil_add_2_form: "돌망태", soil_add_3_form: "바자" }; + for (const [facility, key, container] of [ + ["ford_bridge", "outlet_type", "ford"], + ["box_culvert", "wing_out", "body"], + ]) { + stored = 0; + body = container; + form.setFacility(facility, { [key]: "흙막이", ...old }); + spec = specOf(); + const steps = {}; + for (const n of ["2", "0"]) { + const input = tierInput(); + input.value = n; + input.dispatchEvent(new Event("change")); + await Promise.resolve(); + await Promise.resolve(); + steps[`set${n}`] = { ...snap(), hidden: tierRow()?.hidden ?? null, add: addButton() }; + } + form.setFacility(facility, { [key]: "날개벽", ...old }); + steps.wing = { + boxes: boxesOf("soil_add").length, + hidden: tierRow()?.hidden ?? null, + add: addButton(), + kept: Object.keys(form.readOptions()).filter((k) => k.startsWith("soil_add_")).length, + }; + out[facility] = steps; + } + out.writes = writes; process.stdout.write(JSON.stringify(out)); } main().catch((error) => { diff --git a/resources/tester/test_54_10_soil_tiers.py b/resources/tester/test_54_10_soil_tiers.py index bd116dcda..ec4310e11 100644 --- a/resources/tester/test_54_10_soil_tiers.py +++ b/resources/tester/test_54_10_soil_tiers.py @@ -89,6 +89,8 @@ def test_다단_벽_고르기는_흙막이_컨테이너로() -> None: dock = (ROOT / "B06_Section" / "B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") assert 'fieldset[data-spec-container="soil_add"]' in dock assert "panelRoot.dataset.extraIndex" in dock + # 세월교 · 박스 유출 다단도 그 상자로(90-1 · 유출 자리 = 날개벽 칸) + assert "ownerSlot === sideSlots.outlet || ownerSlot === sideSlots.wingOutlet" in dock panel = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Structure_Panel.ts").read_text( encoding="utf-8" ) diff --git a/resources/tester/test_54_27_soil_tier_field.py b/resources/tester/test_54_27_soil_tier_field.py index 4c55e96a6..5a2ed80d3 100644 --- a/resources/tester/test_54_27_soil_tier_field.py +++ b/resources/tester/test_54_27_soil_tier_field.py @@ -45,7 +45,19 @@ def test_지형_한도를_넘으면_세운_만큼(out) -> None: def test_좌측_칸은_횡단_단_수_자리에_씀(out) -> None: - assert out["writes"] == [[100, 2], [100, 1], [100, 5], [100, 0]] + assert out["writes"][:4] == [[100, 2], [100, 1], [100, 5], [100, 0]] + + +@pytest.mark.parametrize("facility", ["ford_bridge", "box_culvert"]) +def test_세월교_박스_유출도_같은_단_칸(out, facility) -> None: + """90-1 — 단 수 = 흙막이 상자 수 · 「+ 흙막이」 없음 · 유출이 흙막이 아니면 칸 · 옛 상자 숨김(값 남김).""" + steps = out[facility] + assert (steps["set2"]["value"], steps["set2"]["boxes"]) == ("2", 2) + assert (steps["set0"]["value"], steps["set0"]["boxes"]) == ("0", 0) + assert steps["set2"]["structures"] == ["형태", "형태"] + assert not steps["set2"]["hidden"] and not steps["set2"]["add"] + assert steps["wing"] == {"boxes": 0, "hidden": True, "add": False, "kept": 3} + assert out["writes"][4:] == [[100, 2], [100, 0]] * 2 def test_횡단에서_바꾸면_좌측_칸이_따라감(out) -> None: diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 863d2d2a4..95d751985 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -134,18 +134,28 @@ def test_자동_배치_새_세월교는_기본값과_월류_높이(): assert "ford_height_m" not in blind.options # 유량 없으면 빈칸 -def test_집계표_세월교_흙막이_상자_건(): - item = { - "type_id": "ford_bridge", - "options": {"soil_add_1_form": "바자", "soil_add_2_form": "돌망태"}, - "복사본": "pipe@50.0", - "흙막이단": 0, # 배관 단 수는 세월교에 안 걸림 - } - rows = container_rows(item, {"gsoiladd2": "SS000001"}) - assert [(r["그룹"], r["summary_item"]) for r in rows] == [ - ("흙막이 1", None), - ("흙막이 2", "SS000001"), - ] +def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼(): + """90-1 — 관보호공과 같은 방향(단 수 → 상자) · 유출이 흙막이가 아니면 옛 상자는 집계에서 뺌.""" + soil = {"soil_add_1_form": "바자", "soil_add_2_form": "돌망태"} + + def groups(type_id: str, outlet: str, tiers: int | None) -> list[tuple[str, object]]: + key = {"ford_bridge": "outlet_type", "box_culvert": "wing_out"}[type_id] + item = { + "type_id": type_id, + "options": {**soil, key: outlet}, + "복사본": "pipe@50.0", + "흙막이단": tiers, + } + rows = container_rows(item, {"gsoiladd2": "SS000001"}) + return [(r["그룹"], r["summary_item"]) for r in rows] + + for type_id in ("ford_bridge", "box_culvert"): + both = [("흙막이 1", None), ("흙막이 2", "SS000001")] + assert groups(type_id, "흙막이", 2) == both + assert groups(type_id, "흙막이", 1) == both[:1] + assert groups(type_id, "흙막이", None) == both # 모름 = 저장된 것 그대로 + assert groups(type_id, "날개벽", 2) == [] + assert groups(type_id, "", None) == [] def test_B06_측벽_읽기_줄은_세월교_박스_칸에_안_옮김():