From ce4fcc1f13cadf472dcaef2d20bfe582789f0e76 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 13:37:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=86=A0=EC=A0=81=EB=8F=84=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=EB=B3=84=20=EC=9E=90=EB=8F=99=20=EC=B6=95?= =?UTF-8?q?=EC=B2=99=20+=20=EB=82=A9=ED=92=88=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=208=ED=96=89=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 축척 — 노선 연장에 맞춰 **한 장에 들어가는 가장 큰 그림**을 고른다(2026-09-03 사용자 결정). 후보는 도면 관행 축척(1:500~1:6,000, `DRAWING_SCALE_MASSHAUL_H_CANDIDATES`)이고 A1 작도영역 가용 폭 700mm 를 기준으로 판정한다. 실측 — 40m 노선 1:500 · 용화 1,106m 1:2,000 · 3,465m 1:5,000. 세로는 종이 1mm=50㎥ 고정(도면끼리 비교하려면 같아야 한다). 표기 8행(사용자 확정) — ① 장비명 무대→종무대 ② 값 표기 `Q= 162.41M3`(등호 뒤 한 칸) ③ 지시선 계단형·balloon 모서리 접점 ④ 평형선·띠 경계현 빨강 ⑤ 측점 `120+ 0.0` ⑥ 표 눈금 신설(정규 빨강·추가 회색) ⑦ 행 이름 3.2mm·자간 벌림 ⑧ M.N 소수 2자리 `0+16.90`. `MM_H` 상수를 없애고 `mm_h` 를 그리기 함수에 넘긴다 — 축척이 노선마다 달라졌기 때문. 검증 — `tmp/tests/test_b07_masshaul_basin.py` 갱신·신설(축척 3건·표기 8행·지시선·눈금), 전체 378 passed·17 skipped, ruff format 무변경. Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Engine_Cad_MassHaul.py | 193 ++++++++++++------ config/config_system.py | 19 +- 2 files changed, 143 insertions(+), 69 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py index e9b37c2c..899f52b1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py @@ -13,7 +13,8 @@ (정의처 `common_util_mass_haul.massHaulPayload`)을 그대로 읽어 좌표만 종이 mm로 바꾼다 — 곡선 보간·토량 배분 로직을 파이썬에 복제하지 않는다. -좌표 규약: x = 누가거리(m) x MM_H, y = 누가토량(㎥) / 종이 1mm당 토량. +좌표 규약: x = 누가거리(m) x mm_h, y = 누가토량(㎥) / 종이 1mm당 토량. +가로 축척(mm_h)은 노선 연장으로 정한다 — `auto_scale_h()`. """ import math @@ -32,7 +33,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, infer_station_interval, polyline_entity, - station_no_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( entities_bbox, @@ -40,22 +40,45 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( scale_fields, ) from config.config_system import ( + DRAWING_MASSHAUL_USABLE_WIDTH_MM, DRAWING_SCALE_MASSHAUL_H, + DRAWING_SCALE_MASSHAUL_H_CANDIDATES, DRAWING_SCALE_MASSHAUL_V_M3_MM, ) -# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/2,000 -> 0.5). -MM_H = 1000.0 / DRAWING_SCALE_MASSHAUL_H -# 토량 1㎥가 종이에서 차지하는 mm (1 mm = 50㎥ -> 0.02). +# 토량 1㎥가 종이에서 차지하는 mm (1 mm = 50㎥ -> 0.02). 세로는 고정이다 — 축척 분모가 +# 아니라 종이 1 mm 가 받는 토량이라 도면끼리 비교하려면 같아야 한다. MM_V = 1.0 / DRAWING_SCALE_MASSHAUL_V_M3_MM + +def auto_scale_h(length_m: float) -> int: + """노선 연장에 맞는 가로 축척 분모 — **한 장에 들어가는 가장 큰 그림**을 고른다. + + 2026-09-03 사용자 결정(길이별 자동 축척). 후보는 도면 관행 축척뿐이고, 가장 큰 후보로도 + 안 들어가면 그 값을 쓴다(도각 템플릿이 콘텐츠에 맞춰 늘어나므로 잘리지는 않는다). + """ + if length_m <= 0: + return DRAWING_SCALE_MASSHAUL_H + for denominator in sorted(DRAWING_SCALE_MASSHAUL_H_CANDIDATES): + if length_m * 1000.0 / denominator <= DRAWING_MASSHAUL_USABLE_WIDTH_MM: + return denominator + return max(DRAWING_SCALE_MASSHAUL_H_CANDIDATES) + + CURVE_LAYER_ID = "b08-masshaul-curve" CURVE_COLOR = "#ff66ff" AXIS_LAYER_ID = "b08-masshaul-axis" AXIS_COLOR = "#ff4d4d" BAND_LAYER_ID = "b08-masshaul-band" BAND_COLOR = "#ffe066" -BALANCE_COLOR = "#e8edf4" +# 평형선·띠 경계현은 **빨강** — 납품 도면 표기(2026-09-03 사용자 확정). balloon·문자는 +# 종전 노랑 그대로다. +BALANCE_COLOR = "#ff4d4d" +BAND_CHORD_COLOR = "#ff4d4d" +# 표 눈금: 정규 측점은 빨강, 그 사이 추가 측점은 회색(납품 도면 표기). +TABLE_TICK_COLOR = "#ff4d4d" +TABLE_TICK_EXTRA_COLOR = "#9aa5a0" +_TABLE_TICK_LEN = 2.0 TABLE_LAYER_ID = "b08-masshaul-table" # 세로축 눈금 간격(㎥) — 5,000㎥ = 종이 100 mm. @@ -75,7 +98,7 @@ _BALLOON_STAGGER = 3 # 겹침 회피용 층 수 # 장비 키 → 도면 표기(실무 수량산출 용어: 무대·도자·덤프). EQUIPMENT_LABEL = { - "free_haul": "무대", + "free_haul": "종무대", "dozer": "도자", "dump_truck": "덤프", } @@ -86,9 +109,25 @@ _TABLE_ROWS: tuple[tuple[str, str, float], ...] = ( ("station", "측점", 7.0), ) _TABLE_LABEL_WIDTH = 16.0 # 좌측 행 이름 칸 폭(mm) +_TABLE_LABEL_FONT_SIZE = 3.2 # 행 이름은 본문보다 크게(납품 도면 표기) _TABLE_TOP_GAP = 6.0 # 그래프 최저점과 테이블 사이 간격(mm) +def station_plus_label(chainage_m: float, interval_m: float, decimals: int = 1) -> str: + """납품 도면 측점 표기 — `120+ 0.0` (M.N 은 소수 2자리 `0+16.90`). + + 종전에는 `No.120` 이었다. 납품 도면과 표기를 맞춘다(2026-09-03 사용자 확정). + """ + safe = interval_m if interval_m > 0 else 1.0 + number = int((chainage_m + 1e-6) // safe) + remainder = chainage_m - number * safe + if remainder >= safe - 0.05: + number += 1 + remainder = 0.0 + gap = " " if decimals == 1 else "" + return f"{number}+{gap}{remainder:.{decimals}f}" + + def _number(value: Any, fallback: float = 0.0) -> float: return float(value) if isinstance(value, (int, float)) else fallback @@ -105,8 +144,8 @@ def _curve_points(mass_haul: dict[str, Any]) -> list[tuple[float, float]]: ] -def _paper(x_m: float, volume_m3: float) -> tuple[float, float]: - return (x_m * MM_H, volume_m3 * MM_V) +def _paper(x_m: float, volume_m3: float, mm_h: float) -> tuple[float, float]: + return (x_m * mm_h, volume_m3 * MM_V) def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float) -> float: @@ -120,15 +159,15 @@ def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float) def _axis_entities(drawing_id: str, x0: float, min_v: float, max_v: float) -> list[dict[str, Any]]: """좌측 세로축(빨강)·눈금·라벨 + 누가토량 0 기준선.""" entities: list[dict[str, Any]] = [] - top = _paper(0.0, max_v)[1] - bottom = _paper(0.0, min_v)[1] + top = max_v * MM_V + bottom = min_v * MM_V entities.append( _line_entity(f"{drawing_id}:axis:v", (x0, bottom), (x0, top), AXIS_LAYER_ID, AXIS_COLOR) ) start = int(min_v // AXIS_TICK_M3) * AXIS_TICK_M3 value = start while value <= max_v + 1e-6: - y = _paper(0.0, value)[1] + y = value * MM_V entities.append( _line_entity( f"{drawing_id}:axis:tick:{value:.0f}", @@ -202,17 +241,19 @@ def _balloon_entities( ) if shape_entity: entities.append(shape_entity) - # 지시선: balloon 아래(또는 위) 가장자리 → 띠 현 중앙. - edge_y = cy - half_h if anchor[1] < cy else cy + half_h - entities.append( - _line_entity( - f"{drawing_id}:balloon:leader:{seed}", - (cx, edge_y), - anchor, - BAND_LAYER_ID, - BAND_COLOR, - ) + # 지시선은 **계단형**이고 balloon **모서리**에서 나간다(2026-09-03 사용자 확정 — + # 납품 도면 표기). 종전에는 아래 가장자리 중앙에서 대각선 하나로 갔다. + corner_y = cy - half_h if anchor[1] < cy else cy + half_h + corner_x = cx - half_w if anchor[0] < cx else cx + half_w + leader = polyline_entity( + drawing_id, + [(corner_x, corner_y), (anchor[0], corner_y), anchor], + BAND_LAYER_ID, + BAND_COLOR, + suffix=f":balloon:leader:{seed}", ) + if leader: + entities.append(leader) first_y = cy + half_h - _BALLOON_PAD_Y - _BALLOON_LINE_H * 0.75 for index, line in enumerate(lines): entities.append( @@ -230,7 +271,7 @@ def _balloon_entities( def _band_entities( - drawing_id: str, plan: dict[str, Any], curve: list[tuple[float, float]] + drawing_id: str, plan: dict[str, Any], curve: list[tuple[float, float]], mm_h: float ) -> list[dict[str, Any]]: """블록 평형선·띠 경계현·띠 balloon.""" entities: list[dict[str, Any]] = [] @@ -247,8 +288,8 @@ def _band_entities( entities.append( _line_entity( f"{drawing_id}:block:{block.get('index')}", - _paper(from_m, base_m3), - _paper(to_m, base_m3), + _paper(from_m, base_m3, mm_h), + _paper(to_m, base_m3, mm_h), BAND_LAYER_ID, BALANCE_COLOR, ) @@ -266,20 +307,20 @@ def _band_entities( entities.append( _line_entity( f"{drawing_id}:band:boundary:{index}", - _paper(boundary_from, level_base), - _paper(boundary_to, level_base), + _paper(boundary_from, level_base, mm_h), + _paper(boundary_to, level_base, mm_h), BAND_LAYER_ID, - BAND_COLOR, + BAND_CHORD_COLOR, ) ) mid_level = (level_base + level_apex) / 2.0 entities.append( _line_entity( f"{drawing_id}:band:haul:{index}", - _paper(haul_from, mid_level), - _paper(haul_to, mid_level), + _paper(haul_from, mid_level, mm_h), + _paper(haul_to, mid_level, mm_h), BAND_LAYER_ID, - BAND_COLOR, + BAND_CHORD_COLOR, ) ) equipment = str(band.get("equipment") or "") @@ -288,18 +329,16 @@ def _band_entities( # 않아 적지 않는다 (2026-08-30 사용자 확정 — 확인되면 그때 붙인다). lines = [ label, - f"Q={_format(_number(band.get('volume_m3')))}M3", - f"L={_format(_number(band.get('haul_distance_m')))}M", - f"EA={_format(_number(band.get('ea_m3')))}M3", - f"RR={_format(_number(band.get('rr_m3')))}M3", - f"BR={_format(_number(band.get('br_m3')))}M3", + f"Q= {_format(_number(band.get('volume_m3')))}M3", + f"L= {_format(_number(band.get('haul_distance_m')))}M", + f"EA= {_format(_number(band.get('ea_m3')))}M3", + f"RR= {_format(_number(band.get('rr_m3')))}M3", + f"BR= {_format(_number(band.get('br_m3')))}M3", ] - anchor = _paper((haul_from + haul_to) / 2.0, mid_level) + anchor = _paper((haul_from + haul_to) / 2.0, mid_level, mm_h) top_m3 = _curve_top_at(curve, boundary_from, boundary_to) height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y - center_y = ( - _paper(0.0, top_m3)[1] + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER) - ) + center_y = top_m3 * MM_V + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER) entities.extend( _balloon_entities( drawing_id, @@ -319,6 +358,7 @@ def _residual_entities( plan: dict[str, Any], curve: list[tuple[float, float]], interval_m: float, + mm_h: float, ) -> list[dict[str, Any]]: """사토·토취 balloon — 운반거리 대신 발생 측점(M.N)을 적는다.""" entities: list[dict[str, Any]] = [] @@ -334,20 +374,18 @@ def _residual_entities( from_m = _number(residual.get("from_m")) to_m = _number(residual.get("to_m"), from_m) level = _number(residual.get("level_from_m3")) - station = station_no_label(from_m, interval_m).removeprefix("No.") + station = station_plus_label(from_m, interval_m, decimals=2) lines = [ f"{kind} {index}", - f"Q={_format(_number(residual.get('volume_m3')))}M3", - f"M.N={station}", - f"EA={_format(_number(residual.get('ea_m3')))}M3", - f"RR={_format(_number(residual.get('rr_m3')))}M3", - f"BR={_format(_number(residual.get('br_m3')))}M3", + f"Q= {_format(_number(residual.get('volume_m3')))}M3", + f"M.N= {station}", + f"EA= {_format(_number(residual.get('ea_m3')))}M3", + f"RR= {_format(_number(residual.get('rr_m3')))}M3", + f"BR= {_format(_number(residual.get('br_m3')))}M3", ] - anchor = _paper((from_m + to_m) / 2.0, level) + anchor = _paper((from_m + to_m) / 2.0, level, mm_h) height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y - center_y = ( - _paper(0.0, bottom_m3)[1] - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER) - ) + center_y = bottom_m3 * MM_V - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER) entities.extend( _balloon_entities( drawing_id, f"residual:{index}", lines, anchor, (anchor[0], center_y), "rect" @@ -362,6 +400,7 @@ def _table_entities( stations: list[dict[str, Any]], interval_m: float, top_y: float, + mm_h: float, ) -> list[dict[str, Any]]: """하단 2행 테이블(누가토량 / 측점). 값은 세로쓰기, 측점은 No. 표기.""" entities: list[dict[str, Any]] = [] @@ -369,9 +408,10 @@ def _table_entities( return entities cumulative = {round(x, 3): v for x, v in curve} xs = [x for x, _v in curve] - left = min(xs) * MM_H - _TABLE_LABEL_WIDTH - right = max(xs) * MM_H + left = min(xs) * mm_h - _TABLE_LABEL_WIDTH + right = max(xs) * mm_h + safe_interval = interval_m if interval_m > 0 else 1.0 y = top_y boundaries = [y] for _key, _label, height in _TABLE_ROWS: @@ -405,21 +445,21 @@ def _table_entities( entities.append( _text_entity( f"{drawing_id}:table:name:{key}", - label, + " ".join(label), left + _TABLE_LABEL_WIDTH / 2.0, center_y, TABLE_LAYER_ID, - _FONT_SIZE, + _TABLE_LABEL_FONT_SIZE, TABLE_LABEL_COLOR, ) ) for station in stations: chainage = _number(station.get("chainage_m")) - x = chainage * MM_H + x = chainage * mm_h if x < left + _TABLE_LABEL_WIDTH or x > right: continue if key == "station": - text = station_no_label(chainage, interval_m) + text = station_plus_label(chainage, interval_m) y_text = center_y direction = _VERTICAL else: @@ -429,6 +469,19 @@ def _table_entities( text = _format(value) y_text = row_bottom + 0.8 direction = _VERTICAL + # 표 눈금 — 정규 측점(간격의 배수)은 빨강, 그 사이 추가 측점은 회색. + if row_index == 0: + remainder = abs(chainage - round(chainage / safe_interval) * safe_interval) + regular = remainder < 0.05 + entities.append( + _line_entity( + f"{drawing_id}:table:tick:{chainage:.2f}", + (x, row_top), + (x, row_top - _TABLE_TICK_LEN), + TABLE_LAYER_ID, + TABLE_TICK_COLOR if regular else TABLE_TICK_EXTRA_COLOR, + ) + ) entities.append( _text_entity( f"{drawing_id}:table:{key}:{chainage:.2f}", @@ -457,38 +510,45 @@ def build_mass_haul_drawing( interval_m = infer_station_interval(all_stations) volumes = [v for _x, v in curve] min_v, max_v = min(volumes), max(volumes) + # 가로 축척은 노선 연장으로 정한다 — 한 장에 들어가는 가장 큰 그림 + # (2026-09-03 사용자 결정: 길이별 자동 축척). + length_m = max(x for x, _v in curve) - min(x for x, _v in curve) + scale_h = auto_scale_h(length_m) + mm_h = 1000.0 / scale_h entities: list[dict[str, Any]] = [] - x0 = min(x for x, _v in curve) * MM_H + x0 = min(x for x, _v in curve) * mm_h entities.extend(_axis_entities(drawing_id, x0, min(min_v, 0.0), max(max_v, 0.0))) entities.append( _line_entity( f"{drawing_id}:axis:zero", (x0, 0.0), - (max(x for x, _v in curve) * MM_H, 0.0), + (max(x for x, _v in curve) * mm_h, 0.0), AXIS_LAYER_ID, AXIS_COLOR, ) ) curve_entity = polyline_entity( - drawing_id, [_paper(x, v) for x, v in curve], CURVE_LAYER_ID, CURVE_COLOR + drawing_id, [_paper(x, v, mm_h) for x, v in curve], CURVE_LAYER_ID, CURVE_COLOR ) if curve_entity: entities.append(curve_entity) plan = mass_haul.get("haul_plan") if isinstance(plan, dict): - entities.extend(_band_entities(drawing_id, plan, curve)) - entities.extend(_residual_entities(drawing_id, plan, curve, interval_m)) + entities.extend(_band_entities(drawing_id, plan, curve, mm_h)) + entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h)) # 테이블은 그래프·balloon 어느 것보다도 아래에 둔다 — 사토·토취 balloon이 곡선 밑에 # 깔리므로 그래프 최저점만 보고 자리를 잡으면 표와 겹친다(2026-08-30 화면 실측). - graph_bottom = min(_paper(0.0, min_v)[1], 0.0) + graph_bottom = min(min_v * MM_V, 0.0) drawn = entities_bbox(entities) if drawn: graph_bottom = min(graph_bottom, drawn[1]) entities.extend( - _table_entities(drawing_id, curve, all_stations, interval_m, graph_bottom - _TABLE_TOP_GAP) + _table_entities( + drawing_id, curve, all_stations, interval_m, graph_bottom - _TABLE_TOP_GAP, mm_h + ) ) bbox = entities_bbox(entities) @@ -505,10 +565,7 @@ def build_mass_haul_drawing( TABLE_LABEL_COLOR, ) ) - scale_text = ( - f"SCALE H=1:{DRAWING_SCALE_MASSHAUL_H:,} " - f"V=1:{int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000):,}" - ) + scale_text = f"SCALE H=1:{scale_h:,} V=1:{int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000):,}" entities.append( _text_entity( f"{drawing_id}:scale", @@ -526,7 +583,7 @@ def build_mass_haul_drawing( drawing_id, entities_bbox(entities) or bbox, fit=False, - fields={"도면명": "토적도", **scale_fields(("H", DRAWING_SCALE_MASSHAUL_H))}, + fields={"도면명": "토적도", **scale_fields(("H", scale_h))}, ) ) diff --git a/config/config_system.py b/config/config_system.py index 794fa3a8..407c5915 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -831,7 +831,24 @@ DRAWING_SHEET = "A1" # 용지 규격 (840x594 mm 도각 템플릿) # 축척분모를 그대로 못 쓴다 — 종이 1 mm가 받는 토량(50 ㎥)으로 적는다. # 유역도 : 평면 1/6,000 (가로·세로 같은 배율) # A1 유효 작도영역 693x468 mm를 넘으면 늘리지 않고 경고만 남긴다(척도 보존). -DRAWING_SCALE_MASSHAUL_H = 2000 # 유토곡선 가로 축척 분모 (실거리 1 m = 0.5 mm) +DRAWING_SCALE_MASSHAUL_H = 2000 # 유토곡선 가로 축척 분모 (자동 선정 실패 시 폴백) +# 토적도 가로 축척 후보 — 노선 연장에 맞춰 **한 장에 들어가는 가장 큰 그림**을 고른다 +# (2026-09-03 사용자 결정: 길이별 자동 축척). 도면 관행 축척만 둔다. +DRAWING_SCALE_MASSHAUL_H_CANDIDATES = ( + 500, + 600, + 1000, + 1200, + 1500, + 2000, + 2500, + 3000, + 4000, + 5000, + 6000, +) +# A1 작도영역 가로(770 mm)에서 여백 2%와 좌측 행 이름칸·축 여유를 뺀 실제 가용 폭. +DRAWING_MASSHAUL_USABLE_WIDTH_MM = 700.0 DRAWING_SCALE_MASSHAUL_V_M3_MM = 50.0 # 유토곡선 세로 — 종이 1 mm 당 토량(㎥) DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/6 mm)