feat(B07): 토적도 길이별 자동 축척 + 납품 도면 표기 8행 반영

축척 — 노선 연장에 맞춰 **한 장에 들어가는 가장 큰 그림**을 고른다(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) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 13:37:05 +09:00
co-authored by Claude Opus 5
parent 2aaa0c594d
commit ce4fcc1f13
2 changed files with 143 additions and 69 deletions
@@ -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))},
)
)
+18 -1
View File
@@ -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)