feat(b05,b08): 메쌓기 높이 두 기준 사유(교본 7-3:27 · 품셈 L7192 · 칸 경고만) · 성토/절토 칸 face_role — 비우면 자동 판정(B2·B3)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -35,6 +35,9 @@ export interface StructureOptionField {
|
||||
* ⚠ 이 칸의 select 는 **빈 보기를 둔다** — 첫 보기를 슬쩍 고르면 기준값과 다른 값이
|
||||
* 조용히 저장된다(2026-09-13: `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). */
|
||||
empty_means?: string | null;
|
||||
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음) — 기준값과 까닭 한 줄. */
|
||||
warn_above?: number | null;
|
||||
warn_message?: string | null;
|
||||
}
|
||||
|
||||
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
|
||||
|
||||
@@ -921,6 +921,16 @@
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "face_role",
|
||||
"label": "성토/절토",
|
||||
"input": "select",
|
||||
"choices": ["성토", "절토"],
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "foundation",
|
||||
"label": "기초",
|
||||
@@ -990,7 +1000,9 @@
|
||||
"default": 2.5,
|
||||
"required": false,
|
||||
"phase": "b05",
|
||||
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
|
||||
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)",
|
||||
"warn_above": 2.0,
|
||||
"warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)"
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
@@ -1095,6 +1107,16 @@
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "face_role",
|
||||
"label": "성토/절토",
|
||||
"input": "select",
|
||||
"choices": ["성토", "절토"],
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "foundation",
|
||||
"label": "기초",
|
||||
@@ -1995,6 +2017,16 @@
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "face_role",
|
||||
"label": "성토/절토",
|
||||
"input": "select",
|
||||
"choices": ["성토", "절토"],
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "foundation",
|
||||
"label": "기초",
|
||||
|
||||
@@ -60,6 +60,10 @@ class StructureOptionField(BaseModel):
|
||||
# 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」).
|
||||
# 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다.
|
||||
default_basis: str | None = None
|
||||
# 이 값을 **넘으면** 칸이 경고색 + 툴팁 — 막지 않음(2026-09-14 브레인 판정 ①·㉰ 「놓기를 막지 말 것」).
|
||||
# 넘는지 보는 기준값과 그 까닭 한 줄(원문 쪽·줄 번호).
|
||||
warn_above: float | None = None
|
||||
warn_message: str | None = None
|
||||
|
||||
|
||||
class StructureType(BaseModel):
|
||||
|
||||
@@ -40,6 +40,9 @@ interface OptionShape {
|
||||
required?: boolean;
|
||||
/** 비워 두면 어떻게 되나 — 칸에 마우스를 올리면 보임. */
|
||||
empty_means?: string | null;
|
||||
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음). */
|
||||
warn_above?: number | null;
|
||||
warn_message?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +74,17 @@ export function optionControl(
|
||||
element.value = value;
|
||||
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
|
||||
if (option.empty_means) element.title = option.empty_means;
|
||||
const limit = option.warn_above;
|
||||
if (typeof limit === "number") {
|
||||
// 기준을 넘으면 알리기만 — 막지 않음(2026-09-14 브레인 판정 ①·㉰).
|
||||
const warn = (): void => {
|
||||
const over = element.value.trim() !== "" && Number(element.value) > limit;
|
||||
element.classList.toggle("is-outside-standard", over);
|
||||
element.title = over ? (option.warn_message ?? "") : (option.empty_means ?? "");
|
||||
};
|
||||
element.addEventListener("input", warn);
|
||||
warn();
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ def build_standard_sheets(
|
||||
⚠ 계산을 다시 하지 않는다 — 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _section_mode_at
|
||||
from common_util.common_util_structure_face_role import structure_face_role
|
||||
from common_util.common_util_structure_face_role import structure_face_role_of
|
||||
|
||||
groups: dict[str, dict[str, Any]] = {}
|
||||
for structure in unit_table.get("structures") or []:
|
||||
@@ -220,7 +220,7 @@ def build_standard_sheets(
|
||||
# 근거를 못 받아 종전값으로 떨어져 **표와 갈린다**(2026-09-09 실측: 표 1:0.35 ·
|
||||
# 제목 1:0.3). 자리를 고르는 것도 B08 이 쓰는 그 함수를 쓴다.
|
||||
mode = _section_mode_at(structure, section_modes)
|
||||
face, face_reason = structure_face_role(mode, (structure.get("options") or {}).get("side"))
|
||||
face, face_reason = structure_face_role_of(mode, structure.get("options"))
|
||||
structure = {**structure, "face": face, "face_reason": face_reason}
|
||||
key = sheet_key(structure)
|
||||
sheet = groups.get(key)
|
||||
|
||||
@@ -227,7 +227,7 @@ def replace_with_templates(
|
||||
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _section_mode_at
|
||||
from common_util.common_util_structure_face_role import structure_face_role
|
||||
from common_util.common_util_structure_face_role import structure_face_role_of
|
||||
|
||||
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
|
||||
targets: list[tuple[Any, dict[str, Any]]] = []
|
||||
@@ -239,9 +239,7 @@ def replace_with_templates(
|
||||
if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS:
|
||||
continue
|
||||
options = item.get("options") or {}
|
||||
face, face_reason = structure_face_role(
|
||||
_section_mode_at(item, section_modes), options.get("side")
|
||||
)
|
||||
face, face_reason = structure_face_role_of(_section_mode_at(item, section_modes), options)
|
||||
sheet = {
|
||||
"type_id": quantity.type_id,
|
||||
"height_m": quantity.height_m,
|
||||
|
||||
@@ -80,7 +80,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: F
|
||||
weep_hole_spec,
|
||||
)
|
||||
from common_util.common_util_quantity_spread import spread_by_unit
|
||||
from common_util.common_util_structure_face_role import structure_face_role
|
||||
from common_util.common_util_structure_face_role import structure_face_role_of
|
||||
|
||||
# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다).
|
||||
# ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다.
|
||||
@@ -319,7 +319,7 @@ def expand(
|
||||
)
|
||||
return result
|
||||
# 성토/절토 — **판정 한 벌**을 부른다(우리가 따로 짜지 않는다).
|
||||
face, face_reason = structure_face_role(section_mode, options.get("side"))
|
||||
face, face_reason = structure_face_role_of(section_mode, options)
|
||||
result.components, notes = expander(height, length, options, face, face_reason)
|
||||
result.notes.extend(notes)
|
||||
billing = EXPANDER_BILLING.get(type_id)
|
||||
|
||||
@@ -21,6 +21,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import (
|
||||
STONE_PILE_BASIS,
|
||||
_back_length,
|
||||
back_length_default_note,
|
||||
dry_masonry_height_note,
|
||||
face_slope_ratio,
|
||||
fill_concrete_mpa,
|
||||
STONE_SUPPLY_DEFAULT_NOTE,
|
||||
@@ -270,6 +271,9 @@ def stone_masonry(
|
||||
default_note = back_length_default_note(options, back_cm, wet=wet, height_m=height_m)
|
||||
if default_note:
|
||||
notes.append(default_note)
|
||||
height_note = None if wet else dry_masonry_height_note(height_m)
|
||||
if height_note:
|
||||
notes.append(height_note)
|
||||
# ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3).
|
||||
# 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고
|
||||
# 멈추면 이미 저장된 프로젝트가 통째로 빈다.
|
||||
|
||||
@@ -444,6 +444,29 @@ def wants_blinding(options: dict[str, Any]) -> bool:
|
||||
BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm")
|
||||
|
||||
|
||||
#: 메쌓기 높이 — **두 기준이 공존**(2026-09-14 브레인 판정 ㉰ · 지식DB 대조 정합):
|
||||
#: 임도기술교본 7-3:27 「돌흙막이 높이는 원칙적으로 찰쌓기는 3.0m 이하, 메쌓기는 2.0m 이하」 ↔
|
||||
#: 품셈 13-4-4 [주]⑪ 원문 L7192 「돌쌓기는 높이 3m 이상이면 전부 또는 하부를 찰쌓기로 한다」.
|
||||
#: 막지 않고 사유만(판정 ① 「놓기를 막지 말 것」과 한 결).
|
||||
DRY_MASONRY_GUIDE_M = 2.0
|
||||
DRY_MASONRY_LIMIT_M = 3.0
|
||||
|
||||
|
||||
def dry_masonry_height_note(height_m: float) -> str | None:
|
||||
"""메쌓기 높이가 기준을 넘으면 사유 한 줄 — 두 기준을 다 적음."""
|
||||
if height_m >= DRY_MASONRY_LIMIT_M:
|
||||
return (
|
||||
f"⚠ 메쌓기 높이 {height_m:g}m — 품셈 13-4-4 [주]⑪(원문 L7192) 「돌쌓기는 높이 3m 이상이면"
|
||||
" 전부 또는 하부를 찰쌓기로 한다」 · 임도기술교본 7-3:27 은 메쌓기 2.0m 이하 — 막지 않음(설계자 확인)"
|
||||
)
|
||||
if height_m > DRY_MASONRY_GUIDE_M:
|
||||
return (
|
||||
f"⚠ 메쌓기 높이 {height_m:g}m — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」를 넘음"
|
||||
" · 품셈 13-4-4 [주]⑪(원문 L7192)은 3m 이상부터 찰쌓기 — 두 기준이 다름 · 막지 않음"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
#: 뒷길이 표준표 — 품셈 13-4-4 [주]⑩. 안 고른 돌쌓기는 붙박이 45 가 아니라 이 표의 하한
|
||||
#: (2026-09-14 브레인 판정 ㉮ 「범위 안에 드는 규격 중 가장 작은 것」). 파일이 없으면 종전 45.
|
||||
BACK_LENGTH_PREFIX = "masonry_back_length_"
|
||||
|
||||
@@ -59,3 +59,18 @@ def structure_face_role(section_mode: str | None, side: str | None) -> tuple[str
|
||||
return None, f"{mode} · 자동(성토 쪽)인데 성토면이 없음 — 가를 근거 없음"
|
||||
return FILL, f"{mode} · 자동(성토 쪽) → 성토면"
|
||||
return None, f"설치 측을 알 수 없음({side!r}) — 성절토를 가를 근거 없음"
|
||||
|
||||
|
||||
#: 성토/절토를 **사용자가 고르는 칸** — 비우면 위 판정(계산이지 기본값이 아님 · 2026-09-14 브레인 판정 ㉲).
|
||||
FACE_ROLE_KEY = "face_role"
|
||||
|
||||
|
||||
def structure_face_role_of(
|
||||
section_mode: str | None, options: dict | None
|
||||
) -> tuple[str | None, str]:
|
||||
"""칸 `face_role`(성토·절토)을 골랐으면 그 값, 비었으면 단면유형 + 설치 측 판정."""
|
||||
values = options or {}
|
||||
picked = str(values.get(FACE_ROLE_KEY) or "").strip()
|
||||
if picked in (FILL, CUT):
|
||||
return picked, f"사용자 지정 {picked}면(성토/절토 칸)"
|
||||
return structure_face_role(section_mode, values.get("side"))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""메쌓기 높이 사유(B2) · 성토/절토 칸(B3) — 2026-09-14 브레인 판정 ㉰·㉲.
|
||||
|
||||
㉰ 두 기준 공존 — 임도기술교본 7-3:27 「메쌓기 2.0m 이하」 ↔ 품셈 13-4-4 [주]⑪ 원문 L7192
|
||||
「3m 이상이면 전부 또는 하부를 찰쌓기」. 막지 않고 사유(B08 줄) · 칸 경고(B05, `warn_above`).
|
||||
㉲ `face_role` — 비우면 단면유형 + 설치 측 자동 판정(계산이지 기본값이 아님) · 고르면 그 값이 이김.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand, stone_masonry # noqa: E402
|
||||
from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402
|
||||
|
||||
REGISTRY = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
)
|
||||
FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _options(type_id: str) -> dict[str, dict]:
|
||||
item = next(t for t in REGISTRY["types"] if t["type_id"] == type_id)
|
||||
return {o["key"]: o for o in item["options"]}
|
||||
|
||||
|
||||
def _height_notes(height: float, wet: bool) -> list[str]:
|
||||
_rows, notes = stone_masonry(height, 10.0, {"back_len_cm": 45}, wet)
|
||||
return [n for n in notes if "메쌓기 높이" in n]
|
||||
|
||||
|
||||
def test_메쌓기_높이_두_기준_사유() -> None:
|
||||
assert _height_notes(2.0, False) == []
|
||||
over_guide = _height_notes(2.5, False)
|
||||
assert len(over_guide) == 1 and "7-3:27" in over_guide[0] and "L7192" in over_guide[0]
|
||||
over_limit = _height_notes(3.0, False)
|
||||
assert len(over_limit) == 1 and "3m 이상이면" in over_limit[0]
|
||||
assert _height_notes(3.5, True) == [] # 찰쌓기는 이 사유 대상 아님
|
||||
|
||||
|
||||
def test_등록부_메쌓기_높이_칸은_경고만() -> None:
|
||||
height = _options("masonry_dry")["height_m"]
|
||||
assert height["warn_above"] == 2.0 and "L7192" in height["warn_message"]
|
||||
assert "is-outside-standard" in FIELDS and "warn_above" in FIELDS
|
||||
|
||||
|
||||
def test_성토절토_칸이_이긴다() -> None:
|
||||
assert structure_face_role_of("both_fill", {"face_role": "절토"}) == (
|
||||
"절토",
|
||||
"사용자 지정 절토면(성토/절토 칸)",
|
||||
)
|
||||
# 비우면 지금 판정 그대로
|
||||
assert structure_face_role_of("both_fill", {})[0] == "성토"
|
||||
for type_id in ("masonry_wet", "masonry_dry", "revetment"):
|
||||
role = _options(type_id)["face_role"]
|
||||
assert role["choices"] == ["성토", "절토"] and role["default"] is None, type_id
|
||||
|
||||
|
||||
def test_전개가_칸_값으로_기울기를_고른다() -> None:
|
||||
structure = {
|
||||
"structure_id": "w",
|
||||
"type_id": "masonry_wet",
|
||||
"start_m": 0.0,
|
||||
"end_m": 10.0,
|
||||
"options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "face_role": "절토"},
|
||||
}
|
||||
result = expand(structure, None, None, "both_fill")
|
||||
wall = next(c for c in result.components if c.name == "돌쌓기")
|
||||
# 찰쌓기 · 절토 · 직고 ≤3m → 1:0.25 (성토였으면 1:0.30)
|
||||
assert "절토" in wall.basis and "1:0.25" in wall.basis
|
||||
Reference in New Issue
Block a user