diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 52bce692..5a45b7c5 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -802,6 +802,26 @@ "required": false, "phase": "b05" }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, { "key": "back_len_cm", "label": "뒷길이", @@ -944,6 +964,26 @@ "required": false, "phase": "b05" }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, { "key": "back_len_cm", "label": "뒷길이", @@ -1142,6 +1182,26 @@ "phase": "b05", "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, { "key": "back_len_cm", "label": "뒷길이", @@ -1479,6 +1539,26 @@ "required": true, "phase": "detail" }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, { "key": "back_len_cm", "label": "뒷길이 ℓ3", @@ -1759,6 +1839,26 @@ "required": false, "phase": "b05" }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, { "key": "back_len_cm", "label": "뒷길이", diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 28456d41..8fba8d63 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -277,6 +277,8 @@ export function putStandardSheetSpec( foundation: string | null; stone_coeff_basis: string | null; fill_concrete_mpa: string | null; + thickness_top_m: string | null; + thickness_bottom_m: string | null; }, ): Promise<{ status: string; revision: number; changed: number; notes: string[] }> { return requestJson(`/projects/${projectId}/standard-sheets/spec`, { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py index 6cb0e327..78958f67 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py @@ -33,6 +33,8 @@ EDITABLE_KEYS: tuple[str, ...] = ( "foundation", "stone_coeff_basis", "fill_concrete_mpa", + "thickness_top_m", + "thickness_bottom_m", ) #: 기초 갈래 — 정본 xls 탭 제목 그대로(`04.구조도(기슭막이).xls`). @@ -55,6 +57,8 @@ FIELD_LABELS: dict[str, str] = { "foundation": "기초", "stone_coeff_basis": "야면석 계수", "fill_concrete_mpa": "채움 강도", + "thickness_top_m": "상부 두께", + "thickness_bottom_m": "하부 두께", } #: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」. @@ -147,6 +151,26 @@ def clean_spec(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: if note: notes.append(note) + # 벽 두께 — 비우면 실무 구조물도 식(확정 2차 ② 후반). 값을 넣으면 그 값이 이긴다. + for key in ("thickness_top_m", "thickness_bottom_m"): + if key not in spec: + continue + raw = spec.get(key) + if raw in (None, ""): + cleaned[key] = None + continue + try: + value = float(raw) + except (TypeError, ValueError): + notes.append( + f"{FIELD_LABELS[key]} 「{raw}」를 숫자로 읽지 못했습니다 — 비우면 식으로 돕니다." + ) + continue + if value <= 0: + notes.append(f"{FIELD_LABELS[key]}는 0보다 커야 합니다 — 비우면 식으로 돕니다.") + continue + cleaned[key] = value + return cleaned, notes diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py b/B07_DesignDetail/B07_DesignDetail_Router_Standard.py index cae52cd7..ce777eea 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Standard.py @@ -73,6 +73,8 @@ class StandardSheetSpecRequest(BaseModel): foundation: str | None = None stone_coeff_basis: str | None = None fill_concrete_mpa: str | None = None + thickness_top_m: float | str | None = None + thickness_bottom_m: float | str | None = None @router.put("/{project_id}/standard-sheets/spec") diff --git a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts b/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts index aff74c48..1d0e761a 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts @@ -40,11 +40,16 @@ export interface StandardSpecResult { foundation: string | null; stone_coeff_basis: string | null; fill_concrete_mpa: string | null; + thickness_top_m: string | null; + thickness_bottom_m: string | null; } /** 이 종류가 돌쌓기 계열인가 — 옹벽·집수정에는 이 칸들이 뜻이 없다. */ const STONE_TYPES = new Set(["masonry_wet", "masonry_dry", "boulder_masonry"]); +/** 벽 두께 칸을 받는 종류 — **큰돌쌓기는 두께 식이 달라** 등록부에 칸이 없다(2026-09-09). */ +const THICKNESS_TYPES = new Set(["masonry_wet", "masonry_dry"]); + function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement { const wrap = document.createElement("label"); wrap.className = "b07-spec__field"; @@ -126,6 +131,19 @@ export function buildStandardSpecPanel( slope.placeholder = "비우면 자동"; slope.value = options.face_slope_ratio == null ? "" : String(options.face_slope_ratio); + // 벽 두께 — 비우면 실무 구조물도 식(상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H−1)). + const thickness = (key: "thickness_top_m" | "thickness_bottom_m"): HTMLInputElement => { + const input = document.createElement("input"); + input.className = "b07-spec__input"; + input.type = "text"; + input.inputMode = "decimal"; + input.placeholder = "비우면 식"; + input.value = options[key] == null ? "" : String(options[key]); + return input; + }; + const topThickness = thickness("thickness_top_m"); + const bottomThickness = thickness("thickness_bottom_m"); + panel.append( field("돌 종류", kind), field("조달", supply, "비우면 「캔다」로 봅니다."), @@ -139,6 +157,12 @@ export function buildStandardSpecPanel( field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."), field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."), ); + if (THICKNESS_TYPES.has(sheet.type_id)) { + panel.append( + field("상부 두께 (m)", topThickness, "비우면 뒷길이 + 0.30 으로 섭니다."), + field("하부 두께 (m)", bottomThickness, "비우면 상부 + 0.30×(H−1) 로 섭니다."), + ); + } const notes = document.createElement("ul"); notes.className = "b07-spec__notes"; @@ -175,6 +199,12 @@ export function buildStandardSpecPanel( foundation: foundation.value || null, stone_coeff_basis: coeff.value || null, fill_concrete_mpa: mpa.value || null, + thickness_top_m: THICKNESS_TYPES.has(sheet.type_id) + ? topThickness.value.trim() || null + : null, + thickness_bottom_m: THICKNESS_TYPES.has(sheet.type_id) + ? bottomThickness.value.trim() || null + : null, }), ); } catch (error) { diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 5878331e..d0a5c877 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -639,6 +639,56 @@ def _num(value: Any, fallback: float = 0.0) -> float: return float(value) if isinstance(value, (int, float)) else fallback +#: 벽 두께를 **사용자가 덮어쓰는 칸** — 비우면 실무 구조물도 식으로 돈다(확정 2차 ② 후반 +#: 「사용자가 값을 바꿀 수 있게」). 야면석 계수·채움 강도와 같은 자리다. +WALL_THICKNESS_KEYS: tuple[str, str] = ("thickness_top_m", "thickness_bottom_m") + + +def wall_thickness( + options: dict[str, Any], *, back_cm: float, height_m: float +) -> tuple[float, float, str]: + """(상부 두께 m, 하부 두께 m, 근거 문구) — **사용자가 넣은 값이 식을 이긴다.** + + ⚠ 빈 칸은 「안 정함」이다 — 자동으로 나온 값을 저장에 박지 않는다(`face_slope_ratio` + 와 같은 태도). 상부만 넣으면 하부는 **그 상부 위에서** 식을 이어 간다 — 벽 모양 + (아래로 벌어지는 기울기)을 사용자 값 위에 그대로 얹는 것이 자연스럽다. + """ + + def given(key: str) -> float | None: + raw = options.get(key) + if raw in (None, ""): + return None + try: + value = float(raw) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + constants = STONE_MASONRY + back_m = back_cm / 100.0 + top_given = given(WALL_THICKNESS_KEYS[0]) + top = top_given if top_given is not None else back_m + constants["thickness_top_add_m"] + rise = constants["thickness_slope_per_m"] * max( + height_m - constants["thickness_height_base_m"], 0.0 + ) + bottom_given = given(WALL_THICKNESS_KEYS[1]) + bottom = bottom_given if bottom_given is not None else top + rise + top_basis = ( + f"상부 {top:.2f}(사용자 입력)" + if top_given is not None + else f"상부 {top:.2f}(뒷길이 {back_cm:g}㎝ + {constants['thickness_top_add_m']:g})" + ) + bottom_basis = ( + f"하부 {bottom:.2f}(사용자 입력)" + if bottom_given is not None + else ( + f"하부 {bottom:.2f}(상부 + {constants['thickness_slope_per_m']:g}" + f"×(H−{constants['thickness_height_base_m']:g}))" + ) + ) + return top, bottom, f"실무 구조물도 식 — {top_basis} · {bottom_basis}" + + #: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인). #: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가 #: 「뒷길이 45㎝」 계수로 돌던 자리다. @@ -871,16 +921,12 @@ def stone_masonry( # 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04). masonry_area = face_area * math.hypot(1.0, slope_ratio) # 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.** - back_m = back_cm / 100.0 - top_thickness = back_m + constants["thickness_top_add_m"] - bottom_thickness = top_thickness + constants["thickness_slope_per_m"] * max( - height_m - constants["thickness_height_base_m"], 0.0 + # ⚠ 표준도 제원에 상부·하부 두께 칸이 있으면 **그 값이 식을 이긴다**(확정 ② 후반). + back_m = back_cm / 100.0 # 석적(아래)이 뒷길이를 m 로 쓴다 + top_thickness, bottom_thickness, thickness_basis = wall_thickness( + options, back_cm=back_cm, height_m=height_m ) thickness = (top_thickness + bottom_thickness) / 2.0 - thickness_basis = ( - f"실무 구조물도 식 — 상부 {top_thickness:.2f}(뒷길이 {back_cm}㎝ + 0.30) ·" - f" 하부 {bottom_thickness:.2f}(상부 + 0.30×(H−1))" - ) volume = face_area * thickness # 입적 components = [ diff --git a/resources/tester/test_b08_coeff_and_strength.py b/resources/tester/test_b08_coeff_and_strength.py index f39ff16d..d129c7e9 100644 --- a/resources/tester/test_b08_coeff_and_strength.py +++ b/resources/tester/test_b08_coeff_and_strength.py @@ -90,3 +90,45 @@ def test_안_고르면_계산식으로_서고_그_사실이_적힌다() -> None: assert 돌.source == "" # 관측 출처가 아니다 assert "0.77" in 돌.basis and "2.65" in 돌.basis assert any("계산식" in n for n in notes) + + +def test_두께_칸이_비면_식으로_돈다() -> None: + """확정 2차 ② 후반 「사용자가 값을 바꿀 수 있게」 — 빈 칸은 「안 정함」이다.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness + + top, bottom, basis = wall_thickness({}, back_cm=45, height_m=2.5) + assert abs(top - 0.75) < 1e-9 # 뒷길이 0.45 + 0.30 + assert abs(bottom - 1.20) < 1e-9 # 상부 + 0.30 × (2.5 − 1.0) + assert "뒷길이" in basis and "사용자 입력" not in basis + + +def test_넣은_두께가_식을_이긴다() -> None: + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness + + top, bottom, basis = wall_thickness( + {"thickness_top_m": 0.6, "thickness_bottom_m": "1.0"}, back_cm=45, height_m=2.5 + ) + assert (top, bottom) == (0.6, 1.0) + assert basis.count("사용자 입력") == 2 + + +def test_상부만_넣으면_하부는_그_위에서_식을_잇는다() -> None: + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness + + top, bottom, _ = wall_thickness({"thickness_top_m": 0.6}, back_cm=45, height_m=2.5) + assert (top, abs(bottom - 1.05) < 1e-9) == (0.6, True) # 0.6 + 0.30 × 1.5 + + +def test_두께가_입적까지_닿는다() -> None: + """칸만 만들고 계산이 안 읽으면 「값이 안 닿는」 옛 사고와 같아진다.""" + 기본, _ = 성분() + 두꺼움, _ = 성분(thickness_top_m=1.2, thickness_bottom_m=1.6) + assert 두꺼움["입적"].amount > 기본["입적"].amount + + +def test_이상한_값은_식으로_돌린다() -> None: + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import wall_thickness + + for bad in ("", None, "두꺼움", -1, 0): + top, _, basis = wall_thickness({"thickness_top_m": bad}, back_cm=45, height_m=2.5) + assert abs(top - 0.75) < 1e-9 and "사용자 입력" not in basis, bad