From 6926d7fd14a7ae1ffd3edd191ba702777fb0381b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 04:26:48 +0900 Subject: [PATCH] auto: 2026-09-14 04:26 (ESD_LAPTOP) --- ...7_DesignDetail_Engine_Cad_StandardSheet.py | 52 ++- .../B08_Quantity_Engine_StructureFigure.py | 319 ++++++------------ .../B08_Quantity_Router_StructureSheet.py | 3 + .../B08_Quantity_UI_StructureFigure.ts | 80 +++++ .../B08_Quantity_UI_StructureSheet.ts | 11 +- .../tester/test_b07_standard_figure_reason.py | 14 +- .../test_b07_standard_spec_blinding_weep.py | 7 +- resources/tester/test_b08_structure_figure.py | 70 ++++ 8 files changed, 324 insertions(+), 232 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_StructureFigure.ts create mode 100644 resources/tester/test_b08_structure_figure.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py index 0fdbcc22..6657db23 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py @@ -23,19 +23,21 @@ from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, - _text_entity, TABLE_LABEL_COLOR, TABLE_LINE_COLOR, TABLE_VALUE_COLOR, _format, _layer, + _text_entity, + polyline_entity, table_entity, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( +from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox +from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure, figure_reason -from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox +from B08_Quantity.B08_Quantity_Engine_StructureFigure import build_figure as figure_shapes +from B08_Quantity.B08_Quantity_Engine_StructureFigure import figure_reason STANDARD_LAYER_ID = "standard" @@ -66,6 +68,44 @@ def _figure_height(sheet: dict[str, Any]) -> float: return (height_m + 0.95) * FIGURE_SCALE_MM_PER_M if height_m > 0 else 0.0 +def build_figure( + drawing_id: str, sheet: dict[str, Any], origin: tuple[float, float] +) -> tuple[list[dict[str, Any]], float]: + """B08 그림 모양 목록(m)을 CAD 엔티티(종이 ㎜)로. `(엔티티, 높이 ㎜)` — 못 그리면 `([], 0)`.""" + shapes = figure_shapes(sheet) or [] + ox, oy = origin + scale = FIGURE_SCALE_MM_PER_M + entities: list[dict[str, Any]] = [] + for index, shape in enumerate(shapes): + x, y = (shape.get("at") or [0.0, 0.0])[:2] + if shape["kind"] == "text": + entities.append( + _text_entity( + f"{drawing_id}:fig:{index}", + shape["text"], + ox + x * scale, + oy + y * scale, + STANDARD_LAYER_ID, + shape["size"] * scale, + TABLE_LABEL_COLOR, + shape["align"], + ) + ) + continue + poly = polyline_entity( + f"{drawing_id}:fig:{index}", + [(ox + px * scale, oy + py * scale) for px, py in shape["points"]], + STANDARD_LAYER_ID, + TABLE_LINE_COLOR if shape["role"] == "wall" else TABLE_VALUE_COLOR, + dash=[4, 3] if shape["dash"] else None, + width=2 if shape["role"] == "wall" else 1, + ) + if poly is not None: + entities.append(poly) + ys = [point[1] for shape in shapes for point in shape.get("points") or [shape["at"]]] + return entities, ((max(ys) - min(ys)) * scale if ys else 0.0) + + def _cell( text: str, color: str, *, align: str = "left", col_span: int = 1, bold: bool = False ) -> dict[str, Any]: @@ -166,11 +206,7 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any]) figure, used = build_figure( drawing_id, sheet, - STANDARD_LAYER_ID, (x0 + _FIGURE_INSET, top - _FIGURE_TOP_PAD - _figure_height(sheet)), - TABLE_LINE_COLOR, - TABLE_LABEL_COLOR, - TABLE_VALUE_COLOR, ) if figure: entities.extend(figure) diff --git a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py index 53150fea..9aa7803e 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py @@ -1,50 +1,34 @@ -"""표준도 **위쪽 그림** — 돌쌓기 단면(2단계, 2026-09-09). +"""구조물도 **상단 그림** — 돌쌓기 단면 (PLAN 12장 · 3장 한 장의 짜임 「상단 그림」). -실무 원본 탭이 「위 치수조서 + 아래 수량산출서」인데, 아래 표는 1단계에서 섰고 여기가 위다. +⚠ **2026-09-14 B07 에서 이관** — 옛 자리 `B07_DesignDetail_Engine_Cad_StandardFigure.py`. + CAD 엔티티 대신 **모양 목록(선·글자, m 단위, 위가 +y)** 을 냄 — 화면이 SVG 로 그리고(읽기 전용), + CAD 는 뒤 일감이 같은 목록을 받아 씀(B07 `StandardSheet` 가 이미 옮겨 적음). ⚠ **기울기를 값으로 받는다 — 0.3 을 박지 않는다.** - 판정은 **B08 `face_slope_ratio()` 한 벌**을 그대로 부른다(품셈 13-4-4 [주]⑪ 표준경사 — - 직고·메찰·성절토). 사용자가 정했으면 그 값이 이긴다(확정 ⑨). 그림은 **수량이 쓴 바로 - 그 값**으로 기울고, 근거 문구도 같이 받아 그림에 적는다 — 판정을 두 벌로 짜면 그림과 - 수량이 갈린다. + 판정은 B08 `slope_of()` 한 벌(품셈 13-4-4 [주]⑪ 표준경사 — 직고·메찰·성절토). 사용자가 정했으면 + 그 값이 이긴다(확정 ⑨). 그림은 **수량이 쓴 바로 그 값**으로 기울고 근거 문구도 같이 적는다. -⚠ **수량이 쓰는 그 상수로 그린다** — `B08_Quantity_Engine_UnitQuantity.STONE_MASONRY` 를 - 직접 읽는다. 치수를 여기서 다시 적으면 **그림과 표가 갈린다**(CLAUDE.md 5장). +⚠ **두께는 수량표가 쓰는 그 함수**(`UnitQuantity.wall_thickness`) — 사용자가 넣은 상부·하부 두께가 + 식을 이긴다. 옛 B07 그림은 식을 따로 적어 **두께를 넣어도 그림만 옛 두께**로 섰음(이관 때 잡음). 상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H − 1.0) 터파기 폭 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림) ⚠ 터파기 치수는 **`common_util_excavation` 한 벌**을 읽는다 — 횡단도가 쓰는 그 상수다. - 여기서 다시 적으면 두 도면이 다른 터파기를 그린다. - - ⚠ **뒷길이가 두께에 들어간다**(확정 2차 ②, 실무 구조물도 식). 뒷길이가 다르면 벽이 - 두꺼워지고 그림도 그만큼 넓어진다 — 예전 식(0.45+0.10H / 0.45+0.40H)은 뒷길이를 - 아예 안 봐서 35㎝ 든 45㎝ 든 같은 그림이 나왔다. - ⚠ 뒷길이를 안 정한 장은 두께를 못 낸다 — 그때는 **그리지 않는다**(0 으로 때우면 - 거짓 그림이 된다). + ⚠ 뒷길이를 안 정한 장은 **그리지 않고 까닭을 적는다**(브레인 판정 — 빈칸 대신 까닭). ⚠ **치수가 없는 것은 그리되 치수를 적지 않는다** — 막자갈(뒷채움)은 우리 식이 「입적에서 몸통·고임돌을 뺀 것」이라 폭·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다. - 없는 치수를 그림에 적으면 **표와 다른 두 번째 정본**이 생긴다. """ from __future__ import annotations from typing import Any -from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( - _line_entity, - _text_entity, - polyline_entity, -) - -# 기울기 판정은 구조물도 엔진이 가진 한 벌 — 2026-09-13 B08 로 이관(PLAN 3장). 받아 쓰기만 함. -from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: F401 - SLOPE_TYPE_IDS, - slope_of, -) +from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( - STONE_MASONRY, + BACK_LENGTH_KEYS, + wall_thickness, weep_hole_spec, ) from common_util.common_util_excavation import ( @@ -55,15 +39,13 @@ from common_util.common_util_excavation import ( WALL_TRENCH_CLEARANCE_M, ) -#: 그림 축척 — 1/25(1m = 40㎜). 표(줄 높이 9㎜) 위에 얹어도 한 면에 드는 크기다. +#: 그림 축척 — 1/25(1m = 40㎜). CAD 로 옮길 때 글자 크기(㎜)를 m 로 바꾸는 데도 씀. SCALE_MM_PER_M = 40.0 #: 이 그림을 그리는 종류. 옹벽·집수정처럼 단면이 다른 것은 아직 그리지 않는다. -#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 여기 있었지만 그림이 두께를 -#: **뒷길이**에서 내는데 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그렸다** — -#: 목록에만 있고 결과는 늘 빈 그림이었다. 품셈 **13-6(직경) ↔ 13-4(뒷길이)** 로 축이 -#: 갈리는 자리이고 수량 쪽에서 이미 갈라 둔 그것이다(`EXPANDERS` 주석). -#: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄. +#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 그림이 두께를 **뒷길이**에서 내는데 +#: 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그린다** — 품셈 13-6(직경) ↔ +#: 13-4(뒷길이). 「직경에서 두께를 내는 식」은 도메인 판단이라 사용자 몫. FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"}) #: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다 @@ -73,17 +55,29 @@ _NO_FIGURE_REASONS: dict[str, str] = { "그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다" "(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)" ), - "retaining_wall": "그림 없음 — 옹벽은 단면이 달라 아직 그리지 않습니다(표준도 3단계)", + "retaining_wall": "그림 없음 — 옹벽은 단면이 달라 아직 그리지 않습니다", } _NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다" +#: 글자 크기(m) — 종이 ㎜ 를 축척으로 나눈 값(옛 CAD 그림과 같은 크기). +_LABEL_SIZE = 4.0 / SCALE_MM_PER_M +_DIM_SIZE = 3.4 / SCALE_MM_PER_M + + +def back_length_cm(sheet: dict[str, Any]) -> float | None: + """장의 뒷길이(㎝). 안 정했으면 `None` — 그때는 그리지 않는다.""" + options = sheet.get("options") or {} + raw = next( + (options[key] for key in BACK_LENGTH_KEYS if options.get(key) not in (None, "")), None + ) + try: + return float(raw) if raw is not None else None + except (TypeError, ValueError): + return None + def figure_reason(sheet: dict[str, Any]) -> str | None: - """그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`. - - ⚠ 「무엇을 받아야 서는지」까지 적는다 — 뒷길이만 고르면 서는 장과, 식 자체가 없는 장은 - 사용자가 할 일이 다르다. - """ + """그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`.""" type_id = str(sheet.get("type_id") or "") if type_id not in FIGURE_TYPE_IDS: return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT) @@ -94,197 +88,102 @@ def figure_reason(sheet: dict[str, Any]) -> str | None: return None -_LABEL_FONT = 4.0 -_DIM_FONT = 3.4 +def _path(points: list[tuple[float, float]], role: str, *, dash: bool = False) -> dict[str, Any]: + return {"kind": "path", "points": [list(p) for p in points], "role": role, "dash": dash} -def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]: - """(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다.""" - top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"] - bottom_t = top_t + STONE_MASONRY["thickness_slope_per_m"] * max( - height_m - STONE_MASONRY["thickness_height_base_m"], 0.0 - ) - return top_t, bottom_t +def _text( + text: str, at: tuple[float, float], align: str, size: float = _DIM_SIZE +) -> dict[str, Any]: + return {"kind": "text", "text": text, "at": list(at), "align": align, "size": size} -def back_length_cm(sheet: dict[str, Any]) -> float | None: - """장의 뒷길이(㎝). 안 정했으면 `None` — 그때는 두께를 못 내므로 그리지 않는다.""" - options = sheet.get("options") or {} - raw = options.get("back_len_cm") or options.get("stone_back_length_cm") - try: - return float(raw) if raw not in (None, "") else None - except (TypeError, ValueError): +def build_figure(sheet: dict[str, Any]) -> list[dict[str, Any]] | None: + """장 하나의 그림 — 모양 목록(밑면 앞끝이 원점). 못 그리면 `None`(까닭은 `figure_reason`).""" + if figure_reason(sheet) is not None: return None - - -def section_points( - height_m: float, slope_ratio: float, back_cm: float -) -> list[tuple[float, float]]: - """벽 단면 네 점(m 단위, 밑면 앞끝이 원점). 앞면이 뒤로 `n·H` 기운다.""" - top_t, bottom_t = wall_thickness(height_m, back_cm) - lean = slope_ratio * height_m - return [ - (0.0, 0.0), - (lean, height_m), - (lean + top_t, height_m), - (bottom_t, 0.0), - (0.0, 0.0), - ] - - -def build_figure( - drawing_id: str, - sheet: dict[str, Any], - layer_id: str, - origin: tuple[float, float], - line_color: str, - label_color: str, - guide_color: str, -) -> tuple[list[dict[str, Any]], float]: - """장 하나의 그림. `(엔티티, 그림이 차지한 높이 ㎜)` — 못 그리면 `([], 0)`.""" - if str(sheet.get("type_id") or "") not in FIGURE_TYPE_IDS: - return [], 0.0 + options = sheet.get("options") or {} height_m = float(sheet.get("height_m") or 0.0) - if height_m <= 0: - return [], 0.0 - - back_cm = back_length_cm(sheet) - if back_cm is None: - # 뒷길이가 없으면 두께를 못 낸다 — 0 으로 때우지 않고 그리지 않는다. - return [], 0.0 + back_cm = back_length_cm(sheet) or 0.0 slope, slope_note = slope_of(sheet) - scale = SCALE_MM_PER_M - ox, oy = origin - - def mm(point: tuple[float, float]) -> tuple[float, float]: - return (ox + point[0] * scale, oy + point[1] * scale) - - points = section_points(height_m, slope, back_cm) - top_t, bottom_t = wall_thickness(height_m, back_cm) + top_t, bottom_t, _basis = wall_thickness(options, back_cm=back_cm, height_m=height_m) + lean = slope * height_m average_t = (top_t + bottom_t) / 2.0 dig_width = average_t + WALL_TRENCH_CLEARANCE_M - # 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목). - foundation = str((sheet.get("options") or {}).get("foundation") or "") - if foundation == "기초유": - base_w, base_d = WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M - elif foundation == "기초버림": - base_w, base_d = WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M - else: - base_w = base_d = 0.0 # 안 정한 장은 기초를 안 그린다 — 지어내지 않는다. + # 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목). 안 정한 장은 안 그린다. + foundation = str(options.get("foundation") or "") + base_w, base_d = { + "기초유": (WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M), + "기초버림": (WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M), + }.get(foundation, (0.0, 0.0)) - entities: list[dict[str, Any]] = [] - wall = polyline_entity( - f"{drawing_id}:fig:wall", [mm(p) for p in points], layer_id, line_color, width=2 - ) - if wall is not None: - entities.append(wall) - - # 터파기 — 우리 수량식(평균두께+0.2) × 높이 를 **그대로** 그린다. 점선. - dig = polyline_entity( - f"{drawing_id}:fig:dig", - [mm(p) for p in ((0.0, 0.0), (0.0, height_m), (dig_width, height_m), (dig_width, 0.0))], - layer_id, - guide_color, - dash=[4, 3], - ) - if dig is not None: - entities.append(dig) - - # 기초 — 벽 밑에 놓이는 칸. 「안 정함」이면 안 그린다. + shapes = [ + _path( + [(0.0, 0.0), (lean, height_m), (lean + top_t, height_m), (bottom_t, 0.0), (0.0, 0.0)], + "wall", + ), + # 터파기 — 수량식 H × (평균두께 + 0.2) 와 **넓이가 같은** 평행사변형(앞면 따라 기움). + _path( + [ + (0.0, 0.0), + (lean, height_m), + (lean + dig_width, height_m), + (dig_width, 0.0), + (0.0, 0.0), + ], + "guide", + dash=True, + ), + ] if base_d > 0: - base = polyline_entity( - f"{drawing_id}:fig:base", - [mm(p) for p in ((0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0))], - layer_id, - guide_color, + shapes.append( + _path([(0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0)], "guide") ) - if base is not None: - entities.append(base) - - # 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀). - # ⚠ 값은 **그 장의 제원**에서 읽는다 — 수량표와 같은 함수를 쓴다(2026-09-13). 기본 상수를 - # 적으면 사용자가 지름·면적을 고쳐도 그림 주석만 옛 값으로 남아 표와 갈린다. - weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(sheet.get("options") or {}) + # 물구멍 — 벽을 가로지르는 짧은 선. 값은 **그 장의 제원**에서(수량표와 같은 함수). + weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(options) weep_y = height_m * 0.5 - entities.append( - _line_entity( - f"{drawing_id}:fig:weep", - mm((slope * weep_y, weep_y)), - mm((slope * weep_y + top_t, weep_y)), - layer_id, - guide_color, - ) - ) + shapes.append(_path([(slope * weep_y, weep_y), (slope * weep_y + top_t, weep_y)], "guide")) - labels: list[tuple[str, tuple[float, float], str]] = [ - (f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"), - (f"상부 {top_t:.2f} m", (slope * height_m + top_t / 2.0, height_m + 0.14), "center"), - (f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30), "center"), - (f"1 : {slope:g}", (slope * height_m / 2.0 - 0.30, height_m * 0.72), "right"), - ( - f"터파기 폭 {dig_width:.2f} m (평균두께 {average_t:.2f} + 0.20)", - (dig_width + 0.16, height_m * 0.92), + right = max(lean + dig_width, base_w) + 0.16 + shapes += [ + _text(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"), + _text(f"상부 {top_t:.2f} m", (lean + top_t / 2.0, height_m + 0.14), "center"), + _text(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30 - base_d), "center"), + _text(f"1 : {slope:g}", (lean / 2.0 - 0.30, height_m * 0.72), "right"), + _text( + f"터파기 폭 {dig_width:.2f} m " + f"(평균두께 {average_t:.2f} + {WALL_TRENCH_CLEARANCE_M:g})", + (right, height_m * 0.92), "left", ), - ( + _text(f"뒷길이 ℓ₃ = {back_cm:g}㎝", (right, height_m * 0.72), "left"), + _text("막자갈(뒷채움) — 수량은 아래 표", (right, height_m * 0.55), "left"), + _text( + f"물구멍 Ø{weep_diameter_mm} — {weep_area_m2:g}㎡당 1개소", + (right, weep_y - 0.2), + "left", + ), + _text( f"기초 {base_w:g} × {base_d:g} m ({foundation})" if base_d > 0 else "기초 — 안 정함(기초유/기초버림)", - (dig_width + 0.16, -base_d / 2.0 if base_d else 0.0), + (right, -base_d / 2.0 if base_d else 0.0), "left", ), - ( - f"물구멍 Ø{weep_diameter_mm} — {weep_area_m2:g}㎡당 1개소", - (slope * weep_y + top_t + 0.16, weep_y), - "left", - ), - ("막자갈(뒷채움) — 수량은 아래 표", (dig_width + 0.16, height_m * 0.55), "left"), - ] - labels.append((f"뒷길이 ℓ₃ = {int(back_cm)}㎝", (dig_width + 0.16, height_m * 0.72), "left")) - - for index, (text, point, align) in enumerate(labels): - x, y = mm(point) - entities.append( - _text_entity( - f"{drawing_id}:fig:label:{index}", - text, - x, - y, - layer_id, - _DIM_FONT, - label_color, - align, - ) - ) - - # 기울기 근거는 **그림 밑에** 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다. - note_x, note_y = mm((0.0, -0.62)) - entities.append( - _text_entity( - f"{drawing_id}:fig:slopenote", - f"전면 기울기 — {slope_note}", - note_x, - note_y, - layer_id, - _DIM_FONT, - label_color, - "left", - ) - ) - - title_x, title_y = mm((0.0, height_m + 0.52)) - entities.append( - _text_entity( - f"{drawing_id}:fig:title", + # 기울기 근거는 그림 밑에 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다. + _text(f"전면 기울기 — {slope_note}", (0.0, -0.62 - base_d), "left"), + _text( f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})", - title_x, - title_y, - layer_id, - _LABEL_FONT, - label_color, + (0.0, height_m + 0.52), "left", - ) - ) + _LABEL_SIZE, + ), + ] + return shapes - used_mm = (height_m + 0.95 + base_d) * scale - return entities, used_mm + +def attach_figures(payload: dict[str, Any]) -> None: + """장마다 `figure`(모양 목록 또는 `None`)와 `figure_reason` 을 실음 — 화면은 둘 중 하나.""" + for sheet in payload.get("sheets") or []: + sheet["figure_reason"] = figure_reason(sheet) + sheet["figure"] = build_figure(sheet) diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index a80d9d49..04d3e48e 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -52,6 +52,7 @@ def project_structure_sheets( ⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 모듈 맨 위에서 부르면 맞물린다. """ + from B08_Quantity.B08_Quantity_Engine_StructureFigure import attach_figures from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates @@ -74,6 +75,8 @@ def project_structure_sheets( payload = build_standard_sheets(unit_table, section_modes) # 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장). apply_templates(payload, settings, templates) + # 상단 그림(읽기 전용 SVG 자료) — 못 그리는 장은 까닭(PLAN 12장). + attach_figures(payload) # 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다. payload["skipped_structures"] = skipped return payload diff --git a/B08_Quantity/B08_Quantity_UI_StructureFigure.ts b/B08_Quantity/B08_Quantity_UI_StructureFigure.ts new file mode 100644 index 00000000..d2704579 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_StructureFigure.ts @@ -0,0 +1,80 @@ +/* ============================================================================= + * B08_Quantity_UI_StructureFigure.ts + * 구조물도 한 장의 **상단 그림** — 서버가 낸 모양 목록(m, 위가 +y)을 SVG 로 그림 (PLAN 12장). + * + * ⚠ 읽기 전용 — 치수를 셈하지 않음(서버 `B08_Quantity_Engine_StructureFigure` 한 벌). CAD 편집은 뒤. + * ⚠ 못 그리는 장은 빈칸 대신 **까닭**을 같은 자리에 띄움. + * ⚠ 한 장 = 그림 6 : 표 4(실무 비율) — 그림 칸 높이를 화면 높이의 60% 로 둠. + * ========================================================================== */ + +import { el } from "./B08_Quantity_UI_StructureSheet_Formula"; + +export type FigureShape = + | { kind: "path"; points: [number, number][]; role: "wall" | "guide"; dash: boolean } + | { + kind: "text"; + text: string; + at: [number, number]; + align: "left" | "center" | "right"; + size: number; + }; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const ANCHOR = { left: "start", center: "middle", right: "end" } as const; + +export const FIGURE_CSS = ` +.b08-sheet__figure { height: 60vh; min-height: 16rem; border: 1px solid var(--color-border); + background: var(--color-surface); } +.b08-sheet__figure svg { display: block; width: 100%; height: 100%; } +.b08-sheet__figure--none { height: auto; min-height: 0; padding: 6px 10px; } +.b08-fig__wall, .b08-fig__guide { fill: none; vector-effect: non-scaling-stroke; } +.b08-fig__wall { stroke: var(--color-text); stroke-width: 2; } +.b08-fig__guide { stroke: var(--color-text-secondary, #888); stroke-width: 1; } +.b08-fig__guide.is-dash { stroke-dasharray: 6 4; } +.b08-sheet__figure text { fill: var(--color-text); } +`; + +/** 그림 칸 — 모양이 없으면 까닭 한 줄. */ +export function figureSection( + figure: FigureShape[] | null | undefined, + reason: string | null | undefined, +): HTMLElement { + const box = el("div", "b08-sheet__figure"); + if (!figure?.length) { + box.classList.add("b08-sheet__figure--none"); + box.append(el("p", "b08-quantity__message", (reason ?? "그림 없음").replace(/\*\*/g, ""))); + return box; + } + const svg = document.createElementNS(SVG_NS, "svg"); + const group = document.createElementNS(SVG_NS, "g"); + for (const shape of figure) { + if (shape.kind === "text") { + const text = document.createElementNS(SVG_NS, "text"); + text.setAttribute("x", String(shape.at[0])); + text.setAttribute("y", String(-shape.at[1])); + text.setAttribute("font-size", String(shape.size)); + text.setAttribute("text-anchor", ANCHOR[shape.align]); + text.setAttribute("dominant-baseline", "middle"); + text.textContent = shape.text; + group.append(text); + continue; + } + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute("points", shape.points.map(([x, y]) => `${x},${-y}`).join(" ")); + line.setAttribute("class", `b08-fig__${shape.role}${shape.dash ? " is-dash" : ""}`); + group.append(line); + } + svg.append(group); + box.append(svg); + // 보이는 틀은 **그려진 뒤 실제 글자 폭**으로 — 글자 폭을 서버가 짐작하지 않게. + requestAnimationFrame(() => { + if (!svg.isConnected) return; + const b = group.getBBox(); + const pad = Math.max(b.width, b.height) * 0.04; + svg.setAttribute( + "viewBox", + `${b.x - pad} ${b.y - pad} ${b.width + pad * 2} ${b.height + pad * 2}`, + ); + }); + return box; +} diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts index ebea1de3..22394443 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -5,7 +5,7 @@ * ⛔ 탭 등록은 이 파일이 하지 않음 — `B08_Quantity_UI_Page.ts` 탭 배선은 브레인 몫(PLAN 0장 충돌 막이). * 부르는 법: `{ label: "구조물도", build: () => renderStructureSheets(projectId) }` * ⚠ 값을 셈하지 않음 — 서버(`/quantity/structure-sheets`)가 낸 단위당 값을 표기 자리수로만 접음. - * ⚠ 상단 그림·하단 일위대가는 뒤 일감 — 지금은 가운데 원단위 수량표만. + * ⚠ 한 장 = 상단 그림(`_StructureFigure`, 읽기 전용 SVG) + 원단위 수량표 + 하단 일위대가. * ⚠ 제원 저장은 칸 옆 [제원 저장] 한 번에 정본(`structures.json`)으로 감 — 옛 B07 폼 규약 그대로. * ⚠ 식 고치기(PLAN 3장 ⑤) — 칸에서 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂(왕복 없음). * [식 저장] 때는 식만 보내고 **값은 서버가 Node 로 다시 냄**(판정 Ⓐ). 저장은 양식 + 프로젝트 단위. @@ -33,6 +33,7 @@ import { } from "./B08_Quantity_UI_StructureSheet_Spec"; import { buildLibraryPanel, libraryLabel } from "./B08_Quantity_UI_StructureSheet_Library"; import { unitPriceSection } from "./B08_Quantity_UI_StructureSheet_UnitPrice"; +import { FIGURE_CSS, figureSection, type FigureShape } from "./B08_Quantity_UI_StructureFigure"; export interface StructureSheetRow { no: number; @@ -75,6 +76,9 @@ export interface StructureSheet extends StandardSheetSpec { }[]; notes: string[]; unpriced_rows: string[]; + /** 상단 그림 모양 목록 — 못 그리는 장은 `null` + `figure_reason`(PLAN 12장). */ + figure?: FigureShape[] | null; + figure_reason?: string | null; /** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */ /** `imported_from` 없음 = 프로그램 기본을 읽는 중(가져오기 전). */ library_item?: { @@ -144,7 +148,7 @@ function injectStyles(): void { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; - style.textContent = CSS; + style.textContent = CSS + FIGURE_CSS; document.head.append(style); } @@ -256,7 +260,8 @@ function sheetBody( // 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음). el("span", "b08-grid__caption", sheet.unit_label), ); - main.append(head); + // 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭. + main.append(head, figureSection(sheet.figure, sheet.figure_reason)); if (!sheet.rows.length) { main.append( el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"), diff --git a/resources/tester/test_b07_standard_figure_reason.py b/resources/tester/test_b07_standard_figure_reason.py index 24815154..85d6da8b 100644 --- a/resources/tester/test_b07_standard_figure_reason.py +++ b/resources/tester/test_b07_standard_figure_reason.py @@ -19,15 +19,17 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noqa: E402 - FIGURE_TYPE_IDS, - SLOPE_TYPE_IDS, - figure_reason, -) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import ( # noqa: E402 build_standard_drawing, ) -from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_title # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( # noqa: E402 + FIGURE_TYPE_IDS, + figure_reason, +) +from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: E402 + SLOPE_TYPE_IDS, + sheet_title, +) def _sheet(type_id: str, **options) -> dict: diff --git a/resources/tester/test_b07_standard_spec_blinding_weep.py b/resources/tester/test_b07_standard_spec_blinding_weep.py index 6eea04fb..df2d177b 100644 --- a/resources/tester/test_b07_standard_spec_blinding_weep.py +++ b/resources/tester/test_b07_standard_spec_blinding_weep.py @@ -21,7 +21,7 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( # noqa: E402 build_figure, ) from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import ( # noqa: E402 @@ -83,10 +83,7 @@ def _figure_labels(**options) -> list[str]: "height_m": 2.0, "options": {"height_m": 2.0, "back_len_cm": 45, **options}, } - entities, _height = build_figure( - "d1", sheet, "layer", (0.0, 0.0), "#000000", "#111111", "#222222" - ) - return [str((item.get("shapeData") or {}).get("label") or "") for item in entities] + return [shape["text"] for shape in build_figure(sheet) or [] if shape["kind"] == "text"] def test_물빼기_주석이_구조물_값을_쓴다() -> None: diff --git a/resources/tester/test_b08_structure_figure.py b/resources/tester/test_b08_structure_figure.py new file mode 100644 index 00000000..451e02f4 --- /dev/null +++ b/resources/tester/test_b08_structure_figure.py @@ -0,0 +1,70 @@ +"""구조물도 상단 그림 — 그림 치수가 **수량표와 같은 한 벌**인가 (PLAN 12장). + +⚠ 옛 B07 그림은 두께 식을 따로 적어, 상부·하부 두께를 넣어도 **그림만 옛 두께**였음. +""" + +from __future__ import annotations + +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_StructureFigure import ( # noqa: E402 + attach_figures, + build_figure, +) + + +def _sheet(**options) -> dict: + return { + "title": "돌쌓기(메)", + "type_id": "masonry_dry", + "height_m": 2.0, + "options": {"height_m": 2.0, **options}, + } + + +def _paths(shapes: list[dict]) -> list[dict]: + return [shape for shape in shapes if shape["kind"] == "path"] + + +def _area(points: list[list[float]]) -> float: + return abs(sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in zip(points, points[1:]))) / 2 + + +def test_벽_두께는_사용자가_넣은_값을_따른다() -> None: + wall = _paths(build_figure(_sheet(back_len_cm=35, face_slope_ratio=0.3)))[0]["points"] + # 식: 상부 0.35+0.30=0.65 · 하부 0.65+0.30×(2−1)=0.95 · 기울기 0.3 → 앞면 위 끝 0.6 + assert wall[:4] == [[0.0, 0.0], [0.6, 2.0], [1.25, 2.0], [0.95, 0.0]] + given = _paths( + build_figure( + _sheet( + back_len_cm=35, face_slope_ratio=0.3, thickness_top_m=0.5, thickness_bottom_m=1.2 + ) + ) + )[0]["points"] + assert given[2] == [1.1, 2.0] and given[3] == [1.2, 0.0] + + +def test_터파기_넓이는_수량식과_같다() -> None: + dig = _paths(build_figure(_sheet(back_len_cm=35, face_slope_ratio=0.3)))[1] + assert dig["dash"] + assert abs(_area(dig["points"]) - 2.0 * ((0.65 + 0.95) / 2 + 0.2)) < 1e-9 + + +def test_못_그리는_장은_그림_대신_까닭이_실린다() -> None: + payload = { + "sheets": [ + _sheet(), + {**_sheet(stone_cm="60~80"), "type_id": "boulder_masonry"}, + _sheet(back_len_cm=45), + ] + } + attach_figures(payload) + no_back, boulder, drawn = payload["sheets"] + assert no_back["figure"] is None and "뒷길이" in no_back["figure_reason"] + assert boulder["figure"] is None and "직경" in boulder["figure_reason"] + assert drawn["figure_reason"] is None and drawn["figure"]