From 9a850212d5bcdc8a189aa97a71ce48db2eef0418 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:39:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(m02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=B3=84=EB=8F=84=EA=B3=84=EC=83=81=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EC=85=88=EC=97=90=EC=84=9C=20=EB=BA=8C=20=C2=B7=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8=20=EA=B0=99=EC=9D=80=20=EC=B1=85=20=EC=A0=88?= =?UTF-8?q?=20=EB=AA=A8=EB=91=90=20=C2=B7=20=EB=8B=A8=EC=9C=84=20=EA=B9=8C?= =?UTF-8?q?=EB=8B=AD=20=EC=8B=9C=ED=97=98(222-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로직 자재 별도계상(빈 칸 = 기본) = 기준에서 빼고 일위대가 상세 줄에서 지움 · 자재목록엔 남김 - 사용자 자재 별도계상 = 합에서 뺌(파이썬 · TS settle 짝 · 거울 시험) - 사전조합 안 줄 · 프로젝트 사본 셈도 같은 길 - 별도계상 바꾸면 다시 셈(로직 자재 = 서버 · 사용자 자재 = 짝 셈) - 원문 출처 = 로직 출처 + 호표 · 입력 출처 같은 책 절(산림품셈 12-1-2 · 12-1-3) - 기존 시험 셋 = 직접계산 표시 · 대조용 apart 끔 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- M02_MasterTemplete/M02_Labor_Doc.py | 40 ++++- M02_MasterTemplete/M02_Labor_Inputs.py | 24 ++- M02_MasterTemplete/M02_Labor_Ref.py | 3 +- .../M02_MasterTemplete_Labor_Calc.ts | 11 +- .../M02_MasterTemplete_Labor_Detail.ts | 8 +- resources/tester/test_175_precombo_mats.py | 3 +- resources/tester/test_222_1_m02_apart.py | 148 ++++++++++++++++++ resources/tester/test_item_copy_complete.py | 5 +- resources/tester/test_m02_item_labor.py | 2 +- 9 files changed, 230 insertions(+), 14 deletions(-) create mode 100644 resources/tester/test_222_1_m02_apart.py diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 890c7155a..655ec4775 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -20,9 +20,11 @@ 고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와 같은 품목 두 번은 [저장] 때 400. 승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫. -자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 금액 무관) — 사용자 자재 = 줄 덧칸 · +자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 별도계상만 금액에 닿음) — 사용자 자재 = 줄 덧칸 · 로직 자재 = 로직 줄 `자재표시 {호표 차례: {세 칸}}`(차례가 재료 줄인 것만 · 틀리면 버림) · 빈 칸 = 읽을 때 `MARK_DEFAULT`(`mark_of` · 저장은 비움 · 옛 「일반」 = 버림 → 사급 · 162-1). +별도계상(222-1) = 그 자재 금액은 기준 · 합에서 빠지고 로직 `줄` 에서 지움(`drop_apart` · `settle` · +짝 `Labor_Calc.ts`) · `재료` · `자재`(자재목록 · B08 수량)엔 남음. % 덧줄(149-4) = 식 「X * Y / 100」 덧줄에 덧칸 `율`(Y) · `규격`(「노무비의 %」 등 · `pct_extras`) — 표기만 · 단위 · 수량 · 금액 그대로(「식 · 1」) · 화면이 율 · % 로 그림 · M01 시험계산 표도 이 함수. """ @@ -224,6 +226,35 @@ def mark_of(one: Any, key: str) -> str: return marks(one).get(key) or MARK_DEFAULT[key] +def apart(one: Any) -> bool: + """별도계상(222-1) — 세 칸 「별도계상」(빈 칸 = 기본) 자재는 일위대가 셈에서 빠짐(본 프로그램에서 + 따로 계상 · 자재목록은 상세 보기라 따로 안 더함).""" + return mark_of(one, "별도계상") == "별도계상" + + +def drop_apart(got: dict[str, Any], shown: Any) -> dict[str, Any]: + """로직 줄 답(`run_line`)에서 별도계상 로직 자재 빼기(222-1) — 그 재료 줄 금액을 기준에서 빼고 + `줄`(일위대가 상세)에서 지움 · `재료`(자재목록 · 세 칸 고침)는 그대로. + ponytail: 「재료비의 %」 덧줄은 엔진 값 그대로(뺀 재료 몫 안 고침) — 그런 로직이 별도계상을 쓰면 엔진에 넘김.""" + shown = shown if isinstance(shown, dict) else {} + lines = got.get("줄") or [] + out = { + n + for m in got.get("재료") or [] + if (n := m["차례"]) < len(lines) and apart(shown.get(str(n))) + } + if not out or not got.get("기준"): + return got + base = {k: _number(v) or Decimal(0) for k, v in got["기준"].items()} + for n in out: + for k, v in (lines[n].get("비목") or {}).items(): + cut = _number(v) or Decimal(0) + base[k] = base.get(k, Decimal(0)) - cut + base["계"] -= cut + kept = [x for i, x in enumerate(lines) if i not in out] + return {**got, "기준": _json(base), "줄": kept} + + def _mat_marks(value: Any, logic: dict) -> dict[str, dict[str, str]] | None: """로직 자재 세 칸(148-4) `{호표 차례: {세 칸}}` — 맨 위 층 재료 줄 차례 · 허용 값만 · 빈 것 = None.""" @@ -386,6 +417,7 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None} hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []} mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []} + aside = {one.get("코드") for one in doc.get("자재") or [] if apart(one)} # 222-1 합에서 뺌 zero = dict.fromkeys((*COSTS, "계"), Decimal(0)) lines, mats, bases, reflects = [], [], [], [] for line in result.get("줄") or []: @@ -399,9 +431,11 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have) price = _number(one.get("기준단가")) cost = price * qty if price is not None and qty is not None else None + mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) + if one.get("코드") in aside: + continue bases.append({**zero, "재료비": price, "계": price} if price is not None else None) reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None) - mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) @@ -468,7 +502,7 @@ def calc( "배": pill.get("배") or 1, } got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) - lines.append({**line, **got}) + lines.append({**line, **drop_apart(got, one.get("자재표시"))}) mats = [ { "코드": one["코드"], diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py index c782955ac..204102bfe 100644 --- a/M02_MasterTemplete/M02_Labor_Inputs.py +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -8,6 +8,7 @@ from __future__ import annotations +import re from decimal import Decimal from typing import Any @@ -16,6 +17,7 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store _SPEC = ("이름", "설명", "단위", "고르기", "범위") _LAYERED = "골쌓기 기준" # 85-2 — 표 주문에 이 글이 있으면 켜쌓기율 대상 로직 +_NUMBER = re.compile(r"(? Any: @@ -63,6 +65,26 @@ def _labels(whole: Any, spec: dict) -> dict[str, str]: return out +def sources(logic: dict) -> Any: + """원문 출처(222-1) — 로직 `출처` + 호표 · 입력 · 중간 · 덧줄 `출처` 가운데 같은 책(번호 앞 글이 + 같음)의 절 번호를 덧붙임(「산림품셈 12-1-2 · 12-1-3」 · 원문 패널이 절마다 찾음 · M01 + `make.source`) · 다른 책 절은 안 붙임 · 번호 없는 출처는 그대로.""" + main = str(logic.get("출처") or "") + head = _NUMBER.search(main) + if not head: + return logic.get("출처") + book, seen = main[: head.start()], set(_NUMBER.findall(main)) + extra = [] + for part in ("호표", "입력", "중간", "덧줄"): + for row in logic.get(part) or []: + src = str(row.get("출처") or "") if isinstance(row, dict) else "" + hit = _NUMBER.search(src) + if hit and src[: hit.start()] == book and hit.group() not in seen: + seen.add(hit.group()) + extra.append(hit.group()) + return " · ".join([main, *extra]) + + def _listed(logics: list[dict]) -> list[dict[str, Any]]: """로직 줄마다 `{로직, 이름, 결과단위, 출처, 켜쌓기, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`.""" whole = m01.loaded()[1] @@ -84,7 +106,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]: "로직": logic.get("키"), "이름": logic.get("이름"), "결과단위": logic.get("결과단위"), - "출처": logic.get("출처"), + "출처": sources(logic), "켜쌓기": logic.get("키") in layers, "입력": specs, } diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index bc06b9ac0..6e0a14bfb 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -423,6 +423,7 @@ class Expand: got = labor.run_line( self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") ) + got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1) times = labor._number(pill.get("배")) or Decimal(1) base = got.get("기준") return { @@ -463,7 +464,7 @@ class Expand: rows.append({k: v * qty for k, v in sub["기준"].items()}) for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈 qty = labor._number((mat.get("수량") or {}).get("손")) - if qty is None: + if qty is None or labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1) continue got = labor.material_price(self.whole, mat["코드"], mat.get("단위")) price = labor._number(got["기준단가"]) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index 529f6bba4..cea35ce81 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -4,6 +4,7 @@ * 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. + * 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`). * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */ import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; @@ -79,7 +80,7 @@ export interface LaborCalc { /** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */ export interface LaborQtyDoc { 담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[]; - 자재?: { 코드: string; 수량?: Qty }[]; + 자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[]; } function num(raw: unknown): number | null { @@ -130,6 +131,10 @@ export function settle( } const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null])); const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null])); + // 별도계상(222-1) — 「직접계산」 아니면(빈 · 옛 값 = 기본 별도계상) 합에서 뺌(짝 `M02_Labor_Doc.apart`) + const aside = new Set( + (doc.자재 ?? []).filter((m) => m.별도계상 !== "직접계산").map((m) => m.코드), + ); const base = zero(); const done = zero(); const add = (sum: Money, m: Money | null): void => { @@ -146,8 +151,8 @@ export function settle( const [qty, source] = pick(mhands.get(one.코드), one.알약, have); const price = num(one.기준단가); const cost = price !== null && qty !== null ? price * qty : null; - if (price !== null) add(base, { ...zero(), 재료비: price, 계: price }); - if (cost !== null) add(done, { ...zero(), 재료비: cost, 계: cost }); + if (price !== null && !aside.has(one.코드)) add(base, { ...zero(), 재료비: price, 계: price }); + if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost }); return { ...one, 수량: qty, 수량출처: source, 반영: cost }; }); return { 줄, 자재, 기준합: base, 반영합: done }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 71a5e79ce..f0b65b1a6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -383,14 +383,16 @@ export function mountLaborDetail( else delete cur.담은로직[i].승격; opt.onChanged(dirty()); } - // 자재 세 칸(148-4) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 + // 자재 세 칸(148-4) — 화면 캐시만 · 별도계상만 금액에 닿음(222-1 · 사용자 자재 = 짝 셈 · 로직 자재 = 서버 기준) function markMat(i: number, key: MatMarkKey, v: string): void { setMark(cur.자재[i], key, v); - opt.onChanged(dirty()); + if (key === "별도계상") changed(true); + else opt.onChanged(dirty()); } function markLogicMat(i: number, at: number, key: MatMarkKey, v: string): void { setLogicMark(cur.담은로직[i], at, key, v); - opt.onChanged(dirty()); + if (key === "별도계상") changed(); + else opt.onChanged(dirty()); } function removeMat(i: number): void { cur.자재.splice(i, 1); diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 9b98c4cb1..248695cfd 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -79,7 +79,8 @@ def _world_combo(world): # noqa: F811 {"로직": STONE, "수량": {"손": 1}}, {"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}}, ] - mats = [{"코드": MAT, "수량": {"손": 2}}, {"코드": WIRE, "수량": None}] + # MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐) + mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}] with store.at(world["system"]): key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[ "이름" diff --git a/resources/tester/test_222_1_m02_apart.py b/resources/tester/test_222_1_m02_apart.py new file mode 100644 index 000000000..9d13b45f1 --- /dev/null +++ b/resources/tester/test_222_1_m02_apart.py @@ -0,0 +1,148 @@ +"""M02 구조물조합 일위대가 기계비빔타설(PLAN 222-1) — 단위 까닭 · 별도계상 빼기 · 원문 여럿. + +① 단위 — GF000156 모래 · 자갈 = 원문 kg(QF000353) ÷ 단위중량(㎏/㎥) · 칸에 t/㎥ 값(2 · 2.1)을 넣으면 + 838 ÷ 2 = 419 ㎥ 처럼 1000 배 · 0 = 표 위끝(1,700 · 1,800 ㎏/㎥) → 0.49 · 0.57 ㎥ · 칸을 비우면 + 자동값(master_auto 「나누는 자리 = 1」)이 1 이라 838 ㎥(자료 · 기본값 몫 — 223 보고). +② 별도계상 — 로직 자재(`자재표시` · 빈 칸 = 기본 별도계상) = 기준에서 빠지고 `줄` 에서 지움 · `재료` 는 + 남음 · 사용자 자재 = 합에서 뺌(짝 TS `settle`) · 사전조합 안 줄 · 사본 셈 같은 길. +③ 원문 — 로직 출처 + 호표 · 입력 출처 가운데 같은 책 절 번호(「산림품셈 12-1-2 · 12-1-3」). +""" + +import os +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Make as make +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Inputs as inputs +from M02_MasterTemplete import M02_Labor_Ref as refs +from resources.tester.test_m02_labor_calc_mirror import _same, _ts + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete") +MIX = "GF000156" # 기계비빔타설 — 호표 3 시멘트 · 4 모래 · 5 자갈 +GIVEN = {"재료배합": "(A)", "골재최대치수": 40} +MAT = "MT000008" +DIRECT = {"별도계상": "직접계산"} + + +def _whole(): + return m01.loaded()[1] + + +def _rows(got): + return {x["이름"]: {**x, "수량": float(x["수량"])} for x in got["줄"]} + + +# ── ① 단위 ────────────────────────────────────────────────────────── +def test_단위중량_칸에_t_값이면_1000배_0이면_표_위끝(): + wrong = labor.run_line(_whole(), MIX, {**GIVEN, "모래단위중량": 2, "자갈단위중량": 2.1}) + rows = _rows(wrong) + assert rows["시멘트"]["단위"] == "kg" and rows["시멘트"]["수량"] == pytest.approx(335) + assert rows["모래(건조상태)"]["수량"] == pytest.approx(838 / 2) # 419 ㎥ + assert rows["자갈 또는 부순돌"]["수량"] == pytest.approx(1032 / 2.1) # 491.4 ㎥ + zero = {**GIVEN, "모래단위중량": 0, "자갈단위중량": 0} + right = _rows(labor.run_line(_whole(), MIX, zero)) + assert right["모래(건조상태)"]["수량"] == pytest.approx(838 / 1700) + assert right["자갈 또는 부순돌"]["수량"] == pytest.approx(1032 / 1800) + + +# ── ② 별도계상 ─────────────────────────────────────────────────────── +def _calc(shown=None, mats=()): + line = {"로직": MIX, "입력값": GIVEN, "수량": {"손": 1}} + if shown: + line["자재표시"] = shown + doc = labor.clean({"열": "rv", "담은로직": [line], "자재": list(mats)}) + return labor.calc(doc, {}) + + +def test_로직_자재_기본_별도계상은_기준_줄에서_빠지고_재료엔_남음(): + raw = labor.run_line(_whole(), MIX, GIVEN) + cut = sum(Decimal(str(m["금액"])) for m in raw["재료"]) + one = _calc()["줄"][0] + names = {x["이름"] for x in one["줄"]} + assert not names & {"시멘트", "모래(건조상태)", "자갈 또는 부순돌"} + assert {"콘크리트공", "보통인부"} <= names + assert len(one["재료"]) == 3 # 자재목록(세 칸 고침)엔 남음 + want = Decimal(str(raw["기준"]["재료비"])) - cut + assert one["기준"]["재료비"] == pytest.approx(float(want)) + assert one["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - cut)) + assert one["기준"]["노무비"] == pytest.approx(float(raw["기준"]["노무비"])) + + +def test_직접계산은_그대로_섞이면_그_자재만_빠짐(): + raw = labor.run_line(_whole(), MIX, GIVEN) + full = _calc({str(n): DIRECT for n in (3, 4, 5)})["줄"][0] + assert full["기준"]["계"] == pytest.approx(float(raw["기준"]["계"])) + assert len(full["줄"]) == len(raw["줄"]) + part = _calc({"3": DIRECT})["줄"][0] # 시멘트만 셈 + names = {x["이름"] for x in part["줄"]} + assert "시멘트" in names and "모래(건조상태)" not in names + cement = next(m for m in raw["재료"] if m["이름"] == "시멘트") + others = sum(Decimal(str(m["금액"])) for m in raw["재료"]) - Decimal(str(cement["금액"])) + assert part["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - others)) + + +def test_사용자_자재_별도계상은_합에서_빠지고_반영은_보임(): + aside = _calc(None, [{"코드": MAT, "수량": {"손": 2}}]) + direct = _calc(None, [{"코드": MAT, "수량": {"손": 2}, **DIRECT}]) + price = direct["자재"][0]["기준단가"] + assert aside["자재"][0]["반영"] == pytest.approx(price * 2) # 목록엔 금액 보임 + assert direct["반영합"]["계"] - aside["반영합"]["계"] == pytest.approx(price * 2) + assert direct["기준합"]["재료비"] - aside["기준합"]["재료비"] == pytest.approx(price) + + +@pytest.mark.parametrize("mark", [None, "직접계산", "별도계상", "옛값"]) +def test_사용자_자재_별도계상_파이썬과_TS_같은_답(mark): + one = {"코드": MAT, "수량": {"손": 3}, **({"별도계상": mark} if mark else {})} + doc = labor.clean({"열": "rv", "담은로직": [{"로직": MIX, "입력값": GIVEN}], "자재": [one]}) + doc["자재"][0].update({"별도계상": mark} if mark else {}) # 옛 값도 그대로 넘겨 봄 + answer = labor.calc(doc, {}) + values = {"기계비빔타설|㎥": 2} + _same(labor.settle(answer, doc, values), _ts(answer, doc, values)) + + +def test_사전조합_안_줄도_별도계상_뺌(): + raw = labor.run_line(_whole(), MIX, GIVEN) + cut = sum(Decimal(str(m["금액"])) for m in raw["재료"]) + doc = { + "이름": "비빔 조합", + "단위": "㎥", + "담은로직": [{"로직": MIX, "입력값": GIVEN, "수량": {"손": 1}}], + "자재": [ + {"코드": MAT, "수량": {"손": 2}}, # 기본 별도계상 = 안 셈 · 안 줄에도 없음 + ], + } + ref = {"층": "system", "주인": None, "키": "SC999999"} + got = refs.Expand(_whole(), lambda r: doc)(ref) + assert [x.get("자재") for x in got["안"]] == [None] + pill = got["안"][0] + times = Decimal(1) # 기계비빔타설 알약 배 1 + assert pill["기준"]["계"] * times == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut) + assert got["기준"]["계"] == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut) + + +def test_화면_별도계상_바꾸면_다시_셈(): + detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read() + assert 'if (key === "별도계상") changed(true);' in detail # 사용자 자재 = 짝 셈 + assert 'if (key === "별도계상") changed();' in detail # 로직 자재 = 서버 기준 + + +# ── ③ 원문 여럿 ────────────────────────────────────────────────────── +def test_원문_출처는_같은_책_절_모두(): + src = inputs.sources(_whole().logic(MIX)) + assert src == "산림품셈 12-1-2 · 12-1-3" + assert inputs.logic_inputs([MIX])["로직"][0]["출처"] == src + assert [f["number"] for f in make.source(src)["found"]] == ["12-1-2", "12-1-3"] + + +def test_원문_출처_번호_없거나_더할_것_없으면_그대로(): + assert inputs.sources({"출처": "자체"}) == "자체" + assert inputs.sources({"출처": None}) is None + one = { + "출처": "산림품셈 3-1", + "호표": [{"출처": "건설품셈 공통 1-2-4 3"}, {"출처": "산림품셈 3-1"}], + } + assert inputs.sources(one) == "산림품셈 3-1" # 다른 책 · 같은 절은 안 붙임 diff --git a/resources/tester/test_item_copy_complete.py b/resources/tester/test_item_copy_complete.py index 693c02fe5..66f789fa8 100644 --- a/resources/tester/test_item_copy_complete.py +++ b/resources/tester/test_item_copy_complete.py @@ -184,7 +184,10 @@ def test_입력이_없으면_빠진_입력_목록_셈은_자동값(world): assert _stuck(got) == [] and got["기준합"]["계"] > 0 -def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world): +def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world, monkeypatch): + from M02_MasterTemplete import M02_Labor_Doc as labor_doc + + monkeypatch.setattr(labor_doc, "apart", lambda one: False) # M01 조합 셈과 대조(222-1 안 뺌) root, master_dir = world["root"], world["master"] _save(root, [_pipe()]) values = _fill_values(root) diff --git a/resources/tester/test_m02_item_labor.py b/resources/tester/test_m02_item_labor.py index eb480e157..76cf3c1ac 100644 --- a/resources/tester/test_m02_item_labor.py +++ b/resources/tester/test_m02_item_labor.py @@ -87,7 +87,7 @@ def world(tmp_path: Path, monkeypatch, master_copy: Path) -> dict[str, Any]: {"로직": LOGICS[1], "입력값": {name: value}, "수량": {"손": HAND}}, {"로직": LOGICS[2]}, ], - "자재": [{"코드": MATERIAL}], + "자재": [{"코드": MATERIAL, "별도계상": "직접계산"}], # 222-1 합에 셈 } ) _write(system / "labor" / f"{COLUMN}.json", doc) From c98706be06bb9ab5571454be449a9121fc517891 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:43:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(b06):=20=EA=B4=80=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EA=B8=80=20=EB=85=B8=ED=8F=AD=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EC=9C=84=20=C2=B7=20=EA=B4=80=EB=B3=B4=ED=98=B8=EA=B3=B5=200.1?= =?UTF-8?q?=20m=20=EC=9D=B4=EB=8F=99=20=C2=B7=20=EA=B4=80=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=ED=91=9C=EC=8B=9C=20=E3=80=8C8=20m=20(7.6)?= =?UTF-8?q?=E3=80=8D=20=C2=B7=20B08=20=EA=B4=80=EC=97=B0=EC=9E=A5=20?= =?UTF-8?q?=EC=82=AC=EC=82=AC=EC=98=A4=EC=9E=85=20(225-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관 길이 = 유출 벽 자리에서 셈(0.1 m) · 정수 올림 연장 · 길이 맞춤 이동(_Culvert_Fit) 없앰 - 관보호공 ◀▶▲▼ · 끌기 0.1 m 걸음(세월교 흙막이만 1 m) · ◀▶ 때 d 빈 칸은 지금 자리로 묶음 - 관 · 세월교 정보 글 = 노폭 · 노견 라벨 바로 위 줄 · 카드 · 좌측 패널 「정수 m (소수 첫째)」 - 구조물 집계(M02_Table_Fill) 관연장 = 사사오입 정수 · TS pipeLengthWhole 짝 · 새 시험 test_225_1 - 정수 맞춤 없앰에 맞춰 139_6 · 186_1 · 108 · 79 기대값 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- .../design/design_structure_pipe_calc.ts | 1 + .../B05_Profile_UI_Drainage_Facility.ts | 3 +- .../B06_Section_UI_Cross_Body_Guard.ts | 2 +- B06_Section/B06_Section_UI_Cross_Culvert.ts | 8 +- .../B06_Section_UI_Cross_Culvert_Const.ts | 8 + .../B06_Section_UI_Cross_Culvert_Fit.ts | 90 ---------- .../B06_Section_UI_Cross_Culvert_Geom.ts | 103 ++---------- .../B06_Section_UI_Cross_Culvert_Types.ts | 25 +++ B06_Section/B06_Section_UI_Cross_Ford.ts | 16 +- .../B06_Section_UI_Cross_Structure_Panel.ts | 8 +- .../B06_Section_UI_Cross_View_Structure.ts | 11 +- B08_Quantity/B08_Quantity_Router_Summary.py | 2 +- M02_MasterTemplete/M02_Table_Fill.py | 6 +- resources/tester/helper_225_1_pipe_round.cjs | 154 ++++++++++++++++++ resources/tester/test_108_drag_box_hit.py | 4 +- resources/tester/test_139_6_pipe_fit.py | 12 +- resources/tester/test_186_1_outlet_fold.py | 3 +- resources/tester/test_225_1_pipe_round.py | 117 +++++++++++++ resources/tester/test_79_guard_shape.py | 4 +- 19 files changed, 364 insertions(+), 213 deletions(-) delete mode 100644 B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts create mode 100644 resources/tester/helper_225_1_pipe_round.cjs create mode 100644 resources/tester/test_225_1_pipe_round.py diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index 54b6bb940..3f8223d5c 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -12,6 +12,7 @@ import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch"; import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts"; export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin"; +export { pipeLengthText } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; // 「8 m (7.6)」(225-1) import { PIPE_MATCH_M } from "./design_selection"; /** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a58f42145..fb5437511 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -70,6 +70,7 @@ import { pipeDrawn, pipeExtraCount, pipeLengthM, + pipeLengthText, } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, @@ -339,7 +340,7 @@ export function createFacilityOptionsForm( auto: (_c, item) => { if (item.id !== "length") return ""; const length = pipeLengthM(chainage); - return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length)); + return length === null ? "횡단 설계 뒤 셈" : pipeLengthText(length); }, // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index e0cb89151..d9e5c8461 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -147,7 +147,7 @@ export type BodyAxes = { x: number; d: number; through: number; outward: number * 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 · * 수량 L 은 그린 관 길이 올림 그대로). 노견을 넓힌 쪽(139-6)은 사면 우선 — 그 쪽 흙막이는 안 밀고 * 반대쪽(안 넓힌 쪽)을 밂 · 둘 다 넓혔으면 표기만 올림. - * ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 배관 `_Culvert_Fit` 꼴로. */ + * ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 높이 역산을 더함. */ export function fordPipeFit( section: CrossSection, samples: SectionSample[], diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 640890541..6413b563e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -21,7 +21,8 @@ import type { PipeEnd, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Geom"; -import { outlineBox } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendPipeInfoText, outlineBox } from "./B06_Section_UI_Cross_Culvert_Types"; +import { pipeLengthText } from "./B06_Section_UI_Cross_Culvert_Const"; import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation"; import { appendChuteBand, @@ -305,8 +306,11 @@ export function appendCulvertOverlay( const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : ""; const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter); + const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}`; + // 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1 · B06 카드만 — 그 라벨 없는 덧그림엔 안 그림) + if (!hidePipe) appendPipeInfoText(layer, pipeInfo, null); const tip = - `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipe.lengthM}.0m` + + pipeInfo + ` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` + ` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` + ` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index a6ac1bf2b..73a94ed11 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -13,6 +13,14 @@ import type { SpanRole, SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire"; * 절토측 유입 벽에서 관 invert의 상한(노면 − 관경 − 토피)을 정한다. */ export const MIN_PIPE_COVER_M = 0.5; +/** 관 길이 정수(사사오입 · 0.5 올림 · 225-1) — 저장 값은 실제 길이(0.1 m) 그대로. + * ⚠ 파이썬 짝 `M02_Table_Fill.pipe_lengths_from_designs`(집계 값 · 거울 시험 test_225_1). */ +export const pipeLengthWhole = (lengthM: number): number => Math.floor(lengthM + 0.5); + +/** 관 길이 표시 「8 m (7.6)」(225-1 — 카드 글 · 좌측 패널). */ +export const pipeLengthText = (lengthM: number): string => + `${pipeLengthWhole(lengthM)} m (${lengthM.toFixed(1)})`; + /** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */ export const REVET_THICKNESS_M = 0.45; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts deleted file mode 100644 index 022a7f12f..000000000 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Cross_Culvert_Fit.ts - * 관 길이 **m 단위 맞춤** — 배수관 기하(`_Cross_Culvert_Geom.ts`)에서 700줄 제한으로 분리. - * - * · 보통(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 벽 자리를 관 축 방향 바깥으로 - * 민다(벽 두께 0.45 · 높이 고정 · 벽 상단은 1:1.2 성토선을 탐). - * · 노견을 넓힌 쪽(139-6 설계선 쉬프트) — 사면 우선: 벽을 관 축을 따라 옮기되 벽 상단이 그 쪽 - * 성토선(넓힌 노견에서 같은 물매)에 닿도록 **벽 높이를 0.1 m 눈금으로 역산**. 눈금 탓에 남는 - * 차는 표기 올림. - * ========================================================================== */ - -import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; -import { - FILL_SLOPE_MAX_LENGTH_M, - FILL_SLOPE_RATIO_MAX, - FILL_SLOPE_RATIO_MIN, - REVET_EMBED_DEPTH_M, - REVET_HEIGHT_STEP_M, - WALL_MIN_PURE_HEIGHT_M, -} from "./B06_Section_UI_Cross_Culvert_Const"; - -export interface OutletFitInput { - /** 유출 쪽 노견을 넓혔나(139-6) — 높이 역산 갈래. */ - shifted: boolean; - edge: { offset_m: number; elevation_m: number }; - outward: number; - axis: () => OffsetPoint; - thickness: number; - anchor: OffsetPoint; - /** 계산용 높이(순수 − 근입). */ - height: number; - /** 보통 갈래의 invert 선(높이 고정 · 성토선 평행). */ - lineInvertAt: (offset: number) => number; - slopeAt: ( - offset: number, - thickness: number, - height: number, - ) => { ratio: number; lengthM: number }; - cutLength: () => number; - /** 벽을 새 자리 · 높이로 다시 세우고 관 축 길이 `target` 으로 겨눔 — 벽이 안 서면 false. */ - rebuild: (anchor: OffsetPoint, height: number, target: number) => boolean; -} - -/** 맞춤 뒤 벽 자리 · 높이. */ -export function fitOutletPipeLength(c: OutletFitInput): { anchor: OffsetPoint; height: number } { - const target = Math.ceil(c.cutLength() - 1e-6); - let anchor = c.anchor; - let height = c.height; - const ratio0 = c.slopeAt(anchor.offset, c.thickness, height).ratio; - for (let pass = 0; pass < 8; pass += 1) { - const gap = target - c.cutLength(); - if (Math.abs(gap) < 0.005) break; - const axis = c.axis(); - const moved = anchor.offset + axis.offset * gap; - // 노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저. - if ((moved - c.edge.offset_m) * c.outward < 0) break; - if (c.shifted) { - // 사면 우선 — invert 는 관 축을 따라 · 벽 상단 = 같은 물매 성토선 → 높이 역산(0.1 m 눈금) - if (!Number.isFinite(ratio0)) break; - const invert = anchor.elevation + axis.elevation * gap; - const run = Math.abs(moved + c.outward * (c.thickness / 2) - c.edge.offset_m); - const pure = c.edge.elevation_m - run / ratio0 - invert + REVET_EMBED_DEPTH_M; - const stepped = Math.round(pure / REVET_HEIGHT_STEP_M) * REVET_HEIGHT_STEP_M; - if (stepped < WALL_MIN_PURE_HEIGHT_M - 1e-9) break; - const next = stepped - REVET_EMBED_DEPTH_M; - if (Math.abs(next - height) < 1e-9 && Math.abs(moved - anchor.offset) < 0.01) break; - anchor = { offset: moved, elevation: invert }; - height = next; - if (!c.rebuild(anchor, height, target)) break; - continue; - } - const movedSlope = c.slopeAt(moved, c.thickness, height); - // 사면 역전(벽 상단이 노견 위)으로는 옮기지 않는다 · 성토고 0(성토선 0점)까지는 허용(2026-08-23). - if (c.edge.elevation_m - (c.lineInvertAt(moved) + height) < 0) break; - // 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 — - // 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적). - if ( - movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 && - movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && - movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 - ) { - anchor = { offset: moved, elevation: c.lineInvertAt(moved) }; - if (!c.rebuild(anchor, height, target)) break; - continue; - } - // 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다). - break; - } - return { anchor, height }; -} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 70e2cd2de..3e46b469e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -13,6 +13,7 @@ import { FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MIN, MIN_PIPE_COVER_M, + pipeLengthWhole, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, @@ -57,7 +58,6 @@ import { } from "./B06_Section_UI_Cross_Culvert_Basin"; import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import { withBackCut } from "./B06_Section_UI_Cross_Structure_Cut"; -import { fitOutletPipeLength } from "./B06_Section_UI_Cross_Culvert_Fit"; import { buildCulvertTiers, drawnFillPoints, @@ -67,7 +67,6 @@ import { import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { cutLength, - outletSlopeFactory, fillSlopeOf, groundInterpolator, designInterpolator, @@ -116,7 +115,7 @@ export function computeCulvertLayout( false, true, ); - return p ? { shape: p.basin?.shape ?? null, lengthM: p.pipe.lengthM } : null; + return p ? { shape: p.basin?.shape ?? null, lengthM: pipeLengthWhole(p.pipe.lengthM) } : null; }; const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST; const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin); @@ -284,13 +283,9 @@ export function computeCulvertLayout( const rise0 = outletAnchor.elevation - inlet.elevation; const length0 = Math.hypot(run0, rise0); if (!(length0 > 0.5)) return null; - // 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(2026-08-20 사용자 확정). - let lengthM = Math.ceil(length0 - 1e-6); - const scale = lengthM / length0; - const outlet: OffsetPoint = { - offset: inlet.offset + run0 * scale, - elevation: inlet.elevation + rise0 * scale, - }; + // 관 길이 = 그려진 실제 길이(0.1 m · 225-1 — 정수 올림 연장 없음 · 표시 · 집계만 반올림). + let lengthM = Math.round(length0 * 10) / 10; + const outlet: OffsetPoint = { ...outletAnchor }; const span = Math.abs(outletAnchor.offset - inlet.offset); let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0; @@ -418,8 +413,7 @@ export function computeCulvertLayout( // 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21). const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation); let outletWallAnchor = outletAnchor; - // 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5). 관 길이 - // 정수 맞춤 루프가 벽을 옮길 때 invert가 같은 성토선을 타도록 함수로 둔다. + // 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5). const outletZeroAnchor = slopeZeroAnchor( outletInfo.edge, outletInfo.outward, @@ -427,10 +421,6 @@ export function computeCulvertLayout( outletWallSpec.lean, ); const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height; - const outletLineInvertAt = (offset: number): number => - designAt - ? outletZeroInvert - ((offset - outletZeroAnchor) * outletInfo.outward) / FILL_SLOPE_RATIO_MIN - : invertAt(offset); if (designAt) { // 4축 배치(유입과 동일 풀이). const placed = placePipeWall({ @@ -454,7 +444,7 @@ export function computeCulvertLayout( }; outletWallAnchor = { offset: placed.anchorOffset, elevation: placed.invert }; } - let outletWall = buildWall( + const outletWall = buildWall( culvert.outlet, outletWallAnchor, outletInfo.outward, @@ -467,91 +457,28 @@ export function computeCulvertLayout( ); // ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다. const pipeStart = basinPipeEnd ?? inlet; - // 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은 - // 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로. + // 관 끝 = 유출 벽 전면 기준선 교차점 — 벽은 관 길이와 무관하게 0.1 m 로 움직이고 길이는 + // 벽 자리에서 셈(225-1 · 옛 정수 올림 연장 · 길이 맞춤 이동 없앰). if (outletWall) { const face = outletPipeEnd(outletWall); - const runW = face.offset - pipeStart.offset; - const riseW = face.elevation - pipeStart.elevation; - const lenW = Math.hypot(runW, riseW); - if (lenW > 0.5) { - const scaleW = Math.ceil(lenW - 1e-6) / lenW; - outlet.offset = pipeStart.offset + runW * scaleW; - outlet.elevation = pipeStart.elevation + riseW * scaleW; - lengthM = Math.ceil(lenW - 1e-6); + if (Math.hypot(face.offset - pipeStart.offset, face.elevation - pipeStart.elevation) > 0.5) { + outlet.offset = face.offset; + outlet.elevation = face.elevation; } } // ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질 // 때마다 다시 유도한다(2026-08-20 사용자 ③). const pipeAxis = pipeAxisSolver(pipeStart, outlet, diameter, endFaces); - const deriveAxis = pipeAxis.derive; const endCorners = pipeAxis.corners; - let pipeCorners = { + const pipeCorners = { inlet: endCorners("inlet", pipeStart), outlet: endCorners("outlet", outlet), }; - // ── 관 길이 m 단위 맞춤 — `_Culvert_Fit`(보통 = 유출 벽을 관 축 바깥으로 · 노견을 넓힌 - // 쪽 = 사면 우선 + 벽 높이 역산 · 139-6). 정수 맞춤 후보 자리도 같은 성토선을 탄다(2026-08-23). - const outletThickness = REVET_THICKNESS_M; // 늘 0.45 고정(2026-08-21) — 폭으로 길이를 맞추지 않음 - // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다. - // d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값). - const outletPinned = !!outletBody || Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null; - if (outletWall && !outletPinned) { - const extend = - section.design?.[`shoulder_extend_${outletInfo.outward > 0 ? "left" : "right"}_m`]; - fitOutletPipeLength({ - shifted: (extend ?? 0) > 1e-9, - edge: outletInfo.edge, - outward: outletInfo.outward, - axis: () => pipeAxis.axis, - thickness: outletThickness, - anchor: outletWallAnchor, - height: outletWallSpec.height, - lineInvertAt: outletLineInvertAt, - slopeAt: (offset, thickness, height) => - outletSlopeFactory(outletInfo, outletLineInvertAt, height)(offset, thickness), - cutLength: () => cutLength(pipeCorners), - rebuild: (anchor, height, target) => { - outletWallAnchor = anchor; - walls.pop(); - outletWall = buildWall( - culvert.outlet, - anchor, - outletInfo.outward, - null, - outletThickness, - { - height, - baseElevation: anchor.elevation, - floatGapM: Math.max(0, anchor.elevation - invertAt(anchor.offset)), - }, - outletWallSpec.material, - outletWallSpec.form, - outletWallSpec.lean, - ); - if (!outletWall) return false; - // 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①). - const face = outletPipeEnd(outletWall); - const runW = face.offset - pipeStart.offset; - const riseW = face.elevation - pipeStart.elevation; - const lenW = Math.hypot(runW, riseW) || 1; - outlet.offset = pipeStart.offset + (runW / lenW) * target; - outlet.elevation = pipeStart.elevation + (riseW / lenW) * target; - deriveAxis(); - pipeCorners = { - inlet: endCorners("inlet", pipeStart), - outlet: endCorners("outlet", outlet), - }; - return true; - }, - }); - } if (outletWall) { - // 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다). - // 수동 벽으로 실길이가 정수가 아니어도 표기만 올림한다(2026-08-22 사용자 확정). - lengthM = Math.max(1, Math.ceil(cutLength(pipeCorners) - 1e-6)); + // 관 길이 = **실제 그려진 관** 0.1 m(225-1 — 표시 · 집계 반올림은 `pipeLengthWhole`). + lengthM = Math.max(0.1, Math.round(cutLength(pipeCorners) * 10) / 10); slopePct = Math.abs(outlet.offset - pipeStart.offset) > 1e-9 ? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) * diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 94dc53ea0..8ffe3bed8 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -50,6 +50,31 @@ export function outlineBox(points: OffsetPoint[]): OffsetPoint[] { ]; } +/** + * 관 정보 글(세월교 · 관 자리) — 노폭 · 노견 라벨(`.b06-chart__carriageway-label`) 바로 위 줄(225-1). + * 같은 그림에 그 라벨이 없으면(B07 덧그림) `fallback` 자리 · fallback 도 없으면 안 그림. + */ +export function appendPipeInfoText( + layer: SVGElement, + text: string, + fallback: [number, number] | null, +): void { + const svg: Element = layer.ownerSVGElement ?? layer; // 맨 위 svg 는 ownerSVGElement = null + const road = svg.querySelector?.(".b06-chart__carriageway-label"); + if (!road && !fallback) return; + const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); + const stacked = svg.querySelectorAll?.(".b06-chart__pipe-info").length ?? 0; + const [x, y] = road + ? [Number(road.getAttribute("x")), Number(road.getAttribute("y")) - 13 * (stacked + 1)] + : fallback!; + label.setAttribute("x", String(x)); + label.setAttribute("y", String(y)); + label.setAttribute("text-anchor", "middle"); + label.setAttribute("class", "b06-chart__culvert-label b06-chart__pipe-info"); + label.textContent = text; + (road?.parentNode ?? layer).appendChild(label); +} + /** 설계선(성토 경사) 트림 범위 — 이 밖은 벽·보호공이 대신하므로 그리지 않는다. */ export interface CulvertDesignTrim { minOffset: number; diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index 769e7ce9f..fde0f514b 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -10,7 +10,11 @@ * ========================================================================== */ import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom"; -import { outlineBox, type OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { + appendPipeInfoText, + outlineBox, + type OffsetPoint, +} from "./B06_Section_UI_Cross_Culvert_Types"; import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; export { @@ -196,14 +200,8 @@ export function appendFordOverlay( } } - const label = document.createElementNS(SVG_NS, "text"); - const [labelX, labelY] = toXY(layout.label.at); - label.setAttribute("x", String(labelX)); - label.setAttribute("y", String(labelY)); - label.setAttribute("text-anchor", "middle"); - label.setAttribute("class", "b06-chart__culvert-label"); - label.textContent = layout.label.text; - layer.append(label); + // 세월교 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1) · 그 라벨이 없으면 옛 자리. + appendPipeInfoText(layer, layout.label.text, toXY(layout.label.at)); return (key: FordPick | null): void => { for (const [role, shapes] of walls) { diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 0dec26028..57a37320c 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -67,7 +67,7 @@ export interface StructurePanelDeps { bodyGuard?: () => boolean; /** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */ canNudge: (key: RevetKey) => boolean; - /** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */ + /** 좌우·상하 걸음이 0.1m인가(관보호공 · 독립 · 추가 기슭막이). 세월교 흙막이는 false = 1m. */ fineMove: (key: RevetKey) => boolean; /** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */ dragAxes?: (key: RevetKey, basin: boolean) => Omit | null; @@ -102,13 +102,13 @@ export interface StructurePanelHandle { } /** - * 좌우·상하 한 걸음(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정). + * 세월교 흙막이 좌우·상하 한 걸음(m) — 안이 관이라 **1m**(80). * 높이는 0.1m 눈금(2026-08-22 사용자 확정 2~3m·0.5~3m 구간 0.1 단위 제어). */ const STEP_M = 1.0; /** - * 독립·추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m**(2026-08-29 사용자). - * 배관 기준벽(유입·유출)만 관 길이에 맞춘 1m 걸음을 그대로 쓴다. + * 관보호공(배관 기준벽) · 독립 · 추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m** + * (관 길이와 무관 · 225-1). */ const OWN_STEP_M = 0.1; const HEIGHT_STEP_M = 0.1; diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index 00fa535c1..15cb2226a 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -92,9 +92,13 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe return { scrollKey: ctx.scrollKey, adjustFor: ctx.adjustOf, + // 배관 기준벽 d 빈 칸은 지금 자리로 묶음 — x 만 넣으면 옛 기본 d(경사길이 4m · 79b)로 튐(225-1). nudge: (key, screenDeltaM) => ctx.revetOffset?.update(ctx.adjustChainage(), key, { x: ctx.adjustOf(key).x + screenDeltaM * ctx.outwardOf(key), + ...(key === "inlet" || key === "outlet" + ? { d: ctx.adjustOf(key).d ?? ctx.appliedD().get(key) ?? null } + : {}), }), // d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점 // 기준 절대값이라 시작값이 필요하다). @@ -148,12 +152,11 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe resetBasin: () => ctx.inletStructure?.resetAdjust(ctx.adjustChainage()), // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동). canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(), - // 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽· - // 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80). - // 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2). + // 걸음 분류: 관보호공(배관 기준벽)도 관 길이와 무관하게 0.1m(225-1) · 세월교 흙막이만 + // 안이 관이라 1m(80) · 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2). fineMove: (key) => (key !== "inlet" && key !== "outlet") || - (ctx.hiddenPipe() && !ctx.fordGuard?.()) || + !(ctx.hiddenPipe() && ctx.fordGuard?.()) || (ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false), dragAxes: (key, basin) => wallDragAxes(ctx, key, basin), extraState: () => ctx.extraState(), diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 104bcc4ac..20b13dcf9 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -7,7 +7,7 @@ 흙막이 › 형태 › H=높이 m 유입(흙막이일 때) · 유출 · 흙막이 n(실제 선 단까지) · 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이 돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3) - 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로) + 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이 사사오입 정수(225-1) › 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수만 관경 · 련 빈칸 = 「미입력」 열(임의 값으로 안 채움 · 143-2) › 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`) diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 1cb0db1ff..fb34caad1 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -50,7 +50,9 @@ _REF = re.compile(r"\[([^\]$@][^\]@]*)\]") def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]: - """B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음.""" + """B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음. + + 관 연장 = 사사오입 정수(0.5 올림 · 225-1 · ⚠ TS 짝 `pipeLengthWhole` · 거울 시험 test_225_1).""" found: dict[float, float] = {} for row in designs or []: design = row.get("design") if isinstance(row, dict) else None @@ -58,7 +60,7 @@ def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, continue length = _number(design.get("pipe_length_m")) if length and length > 0: - found[round(float(row.get("chainage_m") or 0.0), 3)] = length + found[round(float(row.get("chainage_m") or 0.0), 3)] = int(length + 0.5) return found diff --git a/resources/tester/helper_225_1_pipe_round.cjs b/resources/tester/helper_225_1_pipe_round.cjs new file mode 100644 index 000000000..a56c21a89 --- /dev/null +++ b/resources/tester/helper_225_1_pipe_round.cjs @@ -0,0 +1,154 @@ +/* 225-1 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 헬퍼 — 짝 = `test_225_1_pipe_round.py`. + * 입력 = 인자(반올림 거울 값 JSON 배열) · B07 픽스처 배관 측점(139-6 과 같은 자료). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const noop = new Proxy({}, { get: () => () => "" }); + +/** 아주 작은 SVG 가짜 — 글 자리 판정에 쓰는 것만(class 고르기 · 속성 · 부모). */ +class El { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.parentNode = null; + this.ownerSVGElement = null; + this.textContent = ""; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + appendChild(c) { + c.parentNode = this; + c.ownerSVGElement = this.tag === "svg" ? this : this.ownerSVGElement; + this.children.push(c); + return c; + } + append(...cs) { + cs.forEach((c) => this.appendChild(c)); + } + addEventListener() {} + all() { + return this.children.flatMap((c) => [c, ...c.all()]); + } + querySelectorAll(sel) { + return this.all().filter((e) => (e.attrs.class ?? "").split(" ").includes(sel.slice(1))); + } + querySelector(sel) { + return this.querySelectorAll(sel)[0] ?? null; + } +} +globalThis.document = { addEventListener: () => undefined, createElementNS: (_ns, t) => new El(t) }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const resolve = (base) => + fs.existsSync(base) && fs.statSync(base).isFile() ? base : `${base}.ts`; + const req = (r) => { + if (r.startsWith("@ui/") || r.startsWith("@config/")) return noop; + if (r.startsWith("@util/")) return loadTs(resolve(path.join(ROOT, "common_util", r.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), r))); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(full, box.exports); + return box.exports; +} + +const B06 = (name) => loadTs(path.join(ROOT, "B06_Section", `${name}.ts`)); +const { computeCulvertLayout } = B06("B06_Section_UI_Cross_Culvert_Geom"); +const { cutLength } = B06("B06_Section_UI_Cross_Culvert_Solve"); +const { pipeLengthText, pipeLengthWhole } = B06("B06_Section_UI_Cross_Culvert_Const"); +const { appendPipeInfoText } = B06("B06_Section_UI_Cross_Culvert_Types"); +const { structurePanelDeps } = B06("B06_Section_UI_Cross_View_Structure"); + +// ① 글 자리 — 노폭 · 노견 라벨(x 200 · y 180) 바로 위 줄 · 둘이면 한 줄 더 위 · 라벨 없으면 fallback +const svg = new El("svg"); +const road = new El("text"); +road.setAttribute("class", "b06-chart__carriageway-label"); +road.setAttribute("x", 200); +road.setAttribute("y", 180); +svg.appendChild(road); +const plot = new El("g"); +svg.appendChild(plot); +appendPipeInfoText(plot, "관매설 Φ600m/m, L=8 m (7.6)", null); +appendPipeInfoText(plot, "세월교 L8m", [10, 10]); +const infos = svg.querySelectorAll(".b06-chart__pipe-info").map((e) => ({ + x: Number(e.getAttribute("x")), + y: Number(e.getAttribute("y")), + text: e.textContent, + inSvg: e.parentNode === svg, +})); +const bare = new El("svg"); +appendPipeInfoText(bare, "관", null); +appendPipeInfoText(bare, "세월교", [10, 20]); +const bareInfos = bare.querySelectorAll(".b06-chart__pipe-info").map((e) => ({ + x: Number(e.getAttribute("x")), + y: Number(e.getAttribute("y")), + text: e.textContent, +})); + +// ② 0.1 m 이동 — 걸음 분류 · 유출 벽을 0.1 m 씩 밀면 관 길이가 벽 자리를 따라 0.1 m 로 바뀜 +const fine = (key, hiddenPipe, fordGuard) => + structurePanelDeps({ + hiddenPipe: () => hiddenPipe, + fordGuard: () => fordGuard, + isLinked: false, + }).fineMove(key); +const steps = { + pipeOutlet: fine("outlet", false, false), + pipeInlet: fine("inlet", false, false), + fordGuard: fine("outlet", true, true), + hiddenOwn: fine("outlet", true, false), +}; +const sections = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), +).cross_sections; +const section = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); +const r3 = (v) => Math.round(v * 1000) / 1000; +// ◀/▶ 한 번 = x 0.1 + d 빈 칸은 지금 자리로 묶음(옛 기본 d 로 튀지 않게) +const patches = []; +const nudgeDeps = structurePanelDeps({ + adjustOf: () => ({ x: 0, d: null, h: null, m: null }), + appliedD: () => new Map([["outlet", 0.4]]), + outwardOf: () => 1, + adjustChainage: () => 0, + revetOffset: { update: (_c, key, patch) => patches.push({ key, ...patch }) }, +}); +nudgeDeps.nudge("outlet", 0.1); +nudgeDeps.nudge("extra0", 0.1); +const base = computeCulvertLayout(section, section.samples); +const d0 = base.revetShift.outlet.d; +const moved = [0, 0.1, 0.2, 0.3].map((x) => { + const layout = computeCulvertLayout(section, section.samples, { + outlet: { x, d: x ? d0 : null, h: null, m: null }, + }); + return { x, lengthM: layout.pipe.lengthM, cut: r3(cutLength(layout.pipeCorners)) }; +}); + +// ③ · ④ 표시 · 반올림 거울 +const values = JSON.parse(process.argv[2] ?? "[]"); +process.stdout.write( + JSON.stringify({ + infos, + bareInfos, + steps, + patches, + moved, + text: values.map((v) => pipeLengthText(v)), + whole: values.map((v) => pipeLengthWhole(v)), + }), +); diff --git a/resources/tester/test_108_drag_box_hit.py b/resources/tester/test_108_drag_box_hit.py index 78a1ba35d..a5033b204 100644 --- a/resources/tester/test_108_drag_box_hit.py +++ b/resources/tester/test_108_drag_box_hit.py @@ -31,9 +31,9 @@ def out() -> dict: @pytest.mark.parametrize("side", ["diag1", "diag-1"]) def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None: - """배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수.""" + """배관 기준벽(0.1 m 걸음 · 225-1) — 사면 d · 수평 x 가 한 번에 · 값은 걸음 배수.""" case = out["wall"][side] - assert case["update"] == {"role": "outlet", "x": 1, "d": 4} + assert case["update"] == {"role": "outlet", "x": 1.3, "d": 4} assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"] assert "▼ 2.0m" in case["snap"]["text"] diff --git a/resources/tester/test_139_6_pipe_fit.py b/resources/tester/test_139_6_pipe_fit.py index 34c642776..d2959f13a 100644 --- a/resources/tester/test_139_6_pipe_fit.py +++ b/resources/tester/test_139_6_pipe_fit.py @@ -1,7 +1,7 @@ """139-6 노견을 넓힌 유출 쪽 관 길이 자리 — 사면 우선(물매 1:1.2 그대로) + 유출 벽 높이 역산(0.1 m). 설계선 엔진으로 노견 확장 설계를 내고 배수관 기하를 돌린 값(`helper_139_6_pipe_fit.cjs` · B07 픽스처 -배관 85.052 · 유출 = 좌). 안 넓힌 측점은 종전 맞춤 그대로(나눔 전 코드와 같은 값 · 5.772 → 표기 6). +배관 85.052 · 유출 = 좌). 225-1 부터 정수 길이 맞춤 · 높이 역산 없음 — 관 길이 = 그려진 길이 0.1 m. """ import json @@ -40,8 +40,9 @@ def out(tmp_path_factory) -> dict: def test_안_넓힌_측점은_종전_그대로(out: dict) -> None: + """225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8).""" base = out["base"] - assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 6) + assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.8) assert base["cut"] == pytest.approx(5.772) @@ -51,10 +52,9 @@ def test_넓힌_쪽은_물매_그대로_높이_역산(out: dict, name: str, exte assert case["edge"] == pytest.approx(2 + extend) and case["extend"] == extend assert case["slope"] == pytest.approx(1.2, abs=0.05) # 사면 우선 — 물매 그대로 assert round(case["pureHeight"] * 10) == pytest.approx(case["pureHeight"] * 10) # 0.1 m 눈금 - assert case["lengthM"] - case["cut"] < 0.1 # 눈금 탓 남는 차만 표기 올림 - assert float(case["lengthM"]).is_integer() + assert case["lengthM"] == round(case["cut"], 1) # 225-1 — 길이는 벽 자리에서 셈(올림 없음) def test_높이가_실제로_바뀜(out: dict) -> None: - """대조 — 1.3 m 넓히면 유출 벽 높이가 2.0 → 1.8 로 역산됨(시험이 높이 길을 실제로 잼).""" - assert out["shifted2"]["pureHeight"] == pytest.approx(1.8) + """225-1 — 길이 맞춤 높이 역산 없앰: 1.3 m 넓혀도 유출 벽 높이는 기본 2.0 그대로.""" + assert out["shifted2"]["pureHeight"] == pytest.approx(2.0) diff --git a/resources/tester/test_186_1_outlet_fold.py b/resources/tester/test_186_1_outlet_fold.py index c477551ca..4a173b744 100644 --- a/resources/tester/test_186_1_outlet_fold.py +++ b/resources/tester/test_186_1_outlet_fold.py @@ -63,7 +63,8 @@ def test_저장값_사본_면적(out: dict) -> None: """접힌 폐회로(절토 27.94 · 성토 0.34 ㎡) → 노견~벽~3도 선 절토만.""" assert out["stored"]["cut"] == pytest.approx(34.0391, abs=1e-3) assert out["stored"]["fill"] == 0 - assert out["auto"]["cut"] == pytest.approx(16.8211, abs=1e-3) + # 225-1 — 유출 벽이 관 길이 맞춤으로 밀리지 않고 기본 자리(16.8211 → 16.0929) + assert out["auto"]["cut"] == pytest.approx(16.0929, abs=1e-3) def test_절토_쪽_배관_유출도_안_접힘(out: dict) -> None: diff --git a/resources/tester/test_225_1_pipe_round.py b/resources/tester/test_225_1_pipe_round.py new file mode 100644 index 000000000..ac99b55a7 --- /dev/null +++ b/resources/tester/test_225_1_pipe_round.py @@ -0,0 +1,117 @@ +"""225-1 B06 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 · B08 집계 정수. + +화면 TS 를 그 자리에서 돌린 값(`helper_225_1_pipe_round.cjs`) + 파이썬 집계(`M02_Table_Fill`). +짝(반올림) = TS `pipeLengthWhole`(`B06_Section_UI_Cross_Culvert_Const.ts`) ↔ +파이썬 `pipe_lengths_from_designs` — 사사오입 · 0.5 올림. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from M02_MasterTemplete.M02_Table_Fill import pipe_lengths_from_designs # noqa: E402 + +HELPER = Path(__file__).parent / "helper_225_1_pipe_round.cjs" +VALUES = [0.5, 1.4, 1.5, 2.5, 7.4, 7.5, 7.6, 10.0] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER), json.dumps(VALUES)], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_관_정보_글은_노폭_라벨_바로_위(out: dict) -> None: + """노폭 · 노견 라벨(y 180) 위 줄 = 180 − 13 · 세월교 글이 또 있으면 한 줄 더 위 · 같은 x.""" + pipe, ford = out["infos"] + assert (pipe["x"], pipe["y"], pipe["inSvg"]) == (200, 167, True) + assert pipe["text"] == "관매설 Φ600m/m, L=8 m (7.6)" + assert (ford["x"], ford["y"]) == (200, 154) + + +def test_노폭_라벨_없으면_옛_자리_또는_안_그림(out: dict) -> None: + """B07 덧그림(라벨 없음) — 관 글은 안 그림 · 세월교 글은 옛 자리.""" + assert out["bareInfos"] == [{"x": 10, "y": 20, "text": "세월교"}] + + +def test_관보호공은_0_1m_걸음_세월교_흙막이만_1m(out: dict) -> None: + assert out["steps"] == { + "pipeOutlet": True, + "pipeInlet": True, + "fordGuard": False, + "hiddenOwn": True, + } + + +def test_좌우_한_번은_d_를_지금_자리로_묶음(out: dict) -> None: + """d 빈 칸 그대로 x 만 넣으면 옛 기본 d(경사길이 4 m)로 튐 — 배관 기준벽만 묶음.""" + assert out["patches"] == [ + {"key": "outlet", "x": 0.1, "d": 0.4}, + {"key": "extra0", "x": 0.1}, + ] + + +def test_관_길이는_벽_자리에서_0_1m_로(out: dict) -> None: + """유출 벽을 0.1 m 씩 밀면 관 길이도 0.1 m 씩 — 정수 올림 · 길이 맞춤 이동 없음.""" + lengths = [case["lengthM"] for case in out["moved"]] + assert lengths == [7.9, 8.0, 8.1, 8.2] + for case in out["moved"]: + assert case["lengthM"] == round(case["cut"], 1) + + +def test_표시는_정수_괄호_소수_첫째(out: dict) -> None: + assert out["text"][VALUES.index(7.6)] == "8 m (7.6)" + assert out["text"][VALUES.index(7.4)] == "7 m (7.4)" + assert out["text"][VALUES.index(7.5)] == "8 m (7.5)" + + +def test_B08_집계_값은_사사오입_정수() -> None: + designs = [ + {"chainage_m": 10, "design": {"pipe_length_m": 7.6}}, + {"chainage_m": 20, "design": {"pipe_length_m": 7.5}}, + {"chainage_m": 30, "design": {"pipe_length_m": 7.4}}, + ] + assert pipe_lengths_from_designs(designs) == {10.0: 8, 20.0: 8, 30.0: 7} + + +def test_반올림_짝_거울(out: dict) -> None: + """TS `pipeLengthWhole` ↔ 파이썬 집계 — 같은 값마다 같은 정수(0.5 올림).""" + designs = [{"chainage_m": i, "design": {"pipe_length_m": v}} for i, v in enumerate(VALUES)] + py = pipe_lengths_from_designs(designs) + assert [py[float(i)] for i in range(len(VALUES))] == out["whole"] + assert out["whole"] == [1, 1, 2, 3, 7, 8, 8, 10] + ts = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts").read_text( + encoding="utf-8" + ) + assert "Math.floor(lengthM + 0.5)" in ts and "M02_Table_Fill.pipe_lengths_from_designs" in ts + src = (ROOT / "M02_MasterTemplete" / "M02_Table_Fill.py").read_text(encoding="utf-8") + assert "int(length + 0.5)" in src and "pipeLengthWhole" in src + + +def test_카드_글과_좌측_패널이_같은_표시를_씀() -> None: + card = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert.ts").read_text(encoding="utf-8") + assert ( + "L=${pipeLengthText(pipe.lengthM)}" in card and "appendPipeInfoText(layer, pipeInfo" in card + ) + panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text( + encoding="utf-8" + ) + assert '"횡단 설계 뒤 셈" : pipeLengthText(length)' in panel diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index a89e33d92..8d40967f0 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -111,11 +111,11 @@ def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None: assert pipe is not None # 벽 윗면 = 관 윗면(139-1) · 관 위 토피 0.5 → 유입 벽 윗면 = 노견 − 0.5 = 사면 d 0.6(1:1.2) assert pipe["d"]["inlet"] == pytest.approx(0.6, abs=1e-6) - # 유출은 관 m 단위 맞춤이 사면 아래로 민 만큼만 내려감(종전 기본 = 경사길이 4 m · d 3.07) + # 유출은 기본 자리(225-1 — 관 길이 맞춤 이동 없음 · 종전 기본 = 경사길이 4 m · d 3.07) assert pipe["d"]["outlet"] < 1.0 inlet = next(w for w in pipe["walls"] if w["role"] == "inlet") assert inlet["topBelowEdge"] == pytest.approx(0.5, abs=0.05) - assert float(pipe["lengthM"]).is_integer() + assert round(pipe["lengthM"], 1) == pipe["lengthM"] # 225-1 — 실제 길이 0.1 m def test_저장_x_있는_관_보호공은_종전_자리_그대로(shape: dict) -> None: