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:
2026-09-14 12:42:49 +09:00
co-authored by Claude Opus 5
parent b0355d5ae1
commit 330421fe94
11 changed files with 178 additions and 9 deletions
@@ -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 은 상세
+33 -1
View File
@@ -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