Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into sub_laptop_6
This commit is contained in:
@@ -164,8 +164,8 @@ export const STATE_REGISTRY = {
|
||||
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
|
||||
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
|
||||
crossdesign: { bucket: "draft", scope: "route" },
|
||||
/** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · 배수유역 · B05/B06 값과 안 이음(69-6). */
|
||||
"b08-hydro": { bucket: "draft", scope: "project", version: 2 },
|
||||
/** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */
|
||||
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
||||
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
|
||||
"alignment-draft": {
|
||||
|
||||
@@ -282,6 +282,8 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
||||
{
|
||||
"extra_walls": row["extra_walls"],
|
||||
"wall_fill_overlap": row.get("wall_fill_overlap") or {},
|
||||
# 실제로 선 유입 집수정 모양(I · L · U | None) — TS `ExtraWallRow.inlet_basin` 짝(98-2)
|
||||
"inlet_basin": row.get("inlet_basin"),
|
||||
},
|
||||
)
|
||||
for row in output.get("extra_walls") or []
|
||||
|
||||
@@ -16,7 +16,7 @@ import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
|
||||
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
|
||||
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
computeCardCulvert,
|
||||
culvertLinkFor,
|
||||
@@ -265,6 +265,8 @@ export interface ExtraWallRow {
|
||||
chainage_m: number;
|
||||
extra_walls: BuiltExtraWall[];
|
||||
wall_fill_overlap: Record<string, number>;
|
||||
/** 실제로 선 유입 집수정 모양(patch 형식 · 자동 지형 규칙 결과) · 안 섰으면 null — B08 관보호공(98-2). */
|
||||
inlet_basin: BasinShape | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,7 +317,12 @@ export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[]
|
||||
wall_fill_overlap[wall.role] = Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3));
|
||||
}
|
||||
}
|
||||
rows.push({ chainage_m: section.chainage_m, extra_walls: walls, wall_fill_overlap });
|
||||
rows.push({
|
||||
chainage_m: section.chainage_m,
|
||||
extra_walls: walls,
|
||||
wall_fill_overlap,
|
||||
inlet_basin: culvert.basin?.shape ?? null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""B07 평면도 IP 표 — 곡선표에서 BP · IP · EP 자료를 셈해 장 도곽 왼쪽 아래 표로(PLAN 94장).
|
||||
|
||||
IP 는 곡선이 있는 것만 · 번호는 평면도 「IPn」 과 같은 곡선표 차례다. IA 는 저장 내각(소수 2자리)
|
||||
대신 곡선 자기 접선(시점→교점 · 교점→종점 방향각)으로 다시 셈하고, TL · CL · SL 은 R 과 그 IA 로
|
||||
셈한다. 좌표는 측량 관례대로 X = 북(y) · Y = 동(x).
|
||||
|
||||
표 자리(종이 mm)는 자료를 만들 때 정한다 — 표 밑 등고선 · 노선을 같은 자리로 잘라 내야 해서다.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from shapely.geometry import LineString, MultiLineString
|
||||
from shapely.geometry import box as rect
|
||||
from shapely.ops import unary_union
|
||||
from shapely.prepared import prep
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
DESIGN_CURVE_MATCH_TOLERANCE_M,
|
||||
_project_chainage,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import table_entity
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import _FONT_SIZE, MM
|
||||
|
||||
LAYER_ID = "b07-plan-ip_table"
|
||||
LINE_COLOR = "#ff4d4d" # 예시 표 — 선 빨강 · 글 파랑
|
||||
TEXT_COLOR = "#4d7dff"
|
||||
_COLUMNS = (7.0, 6.0, 23.0) # [머리 | X·Y | 값] 종이 mm
|
||||
_ROW_H = 5.0
|
||||
_MARGIN = 5.0 # 도곽 왼쪽 아래 모서리에서 띄움
|
||||
_LINE_GAP = 3.0 # 한 줄 넘쳐 위로 올린 줄 사이
|
||||
_IP_ROWS = 8 # IP · CO(X · Y) · IA · R · TL · CL · SL
|
||||
_END_ROWS = 3 # BP · EP = IP · CO 만
|
||||
_VALUE_ROWS = (("I A", "ia"), ("R", "r"), ("T L", "tl"), ("C L", "cl"), ("S L", "sl"))
|
||||
_CLIPPED = ("contour", "stream", "road_edge", "fill_outline", "cut_outline", "centerline")
|
||||
|
||||
|
||||
def intersection_angle(start: Any, apex: Any, end: Any) -> float:
|
||||
"""교각 IA(도) — 시점→교점 · 교점→종점 방향각 차."""
|
||||
first = math.atan2(apex[1] - start[1], apex[0] - start[0])
|
||||
second = math.atan2(end[1] - apex[1], end[0] - apex[0])
|
||||
return abs(math.degrees((second - first + math.pi) % (2 * math.pi) - math.pi))
|
||||
|
||||
|
||||
def curve_values(radius: float, ia_deg: float) -> dict[str, float]:
|
||||
"""TL = R·tan(IA/2) · CL = R·IA(rad) · SL = R·(sec(IA/2) − 1)."""
|
||||
half = math.radians(ia_deg) / 2.0
|
||||
return {
|
||||
"tl": radius * math.tan(half),
|
||||
"cl": radius * 2.0 * half,
|
||||
"sl": radius * (1.0 / math.cos(half) - 1.0),
|
||||
}
|
||||
|
||||
|
||||
def dms(degrees: float) -> str:
|
||||
"""도-분-초 「94-59-58.38」 — 분 · 초 두 자리 · 초는 0.01″ 로 반올림해 60.00 이면 올림."""
|
||||
hundredths = round(degrees * 360000)
|
||||
whole, rest = divmod(hundredths, 360000)
|
||||
minutes, seconds = divmod(rest, 6000)
|
||||
return f"{whole}-{minutes:02d}-{seconds // 100:02d}.{seconds % 100:02d}"
|
||||
|
||||
|
||||
def ip_blocks(
|
||||
curves: list[dict[str, Any]],
|
||||
route: list[tuple[float, float, float]],
|
||||
number: int,
|
||||
total: int,
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""그 장의 표 묶음 [BP?, IP…, EP?] — 곡선 가운데 누가거리가 그 장 [시작, 끝) 안인 것만.
|
||||
|
||||
장 경계 측점은 두 장이 나눠 가지므로 끝은 열어 둔다(마지막 장만 닫음) — 한 IP 는 한 장에만.
|
||||
노선에서 5 m 넘게 떨어진 옛 곡선은 버린다(B05 곡선 확폭과 같은 허용).
|
||||
"""
|
||||
if len(route) < 2:
|
||||
return []
|
||||
points = np.asarray([(x, y) for _c, x, y in route], dtype=np.float64)
|
||||
chainage = np.asarray([c for c, _x, _y in route], dtype=np.float64)
|
||||
blocks: list[dict[str, Any]] = []
|
||||
if number == 1:
|
||||
blocks.append({"name": "BP", "x": route[0][1], "y": route[0][2]})
|
||||
for index, curve in enumerate(curves, start=1):
|
||||
start, apex, end = curve.get("start"), curve.get("apex"), curve.get("end")
|
||||
radius = curve.get("radius_m")
|
||||
if not (start and apex and end) or not isinstance(radius, (int, float)):
|
||||
continue
|
||||
start_gap, start_c = _project_chainage(points, chainage, tuple(start[:2]))
|
||||
end_gap, end_c = _project_chainage(points, chainage, tuple(end[:2]))
|
||||
if max(start_gap, end_gap) > DESIGN_CURVE_MATCH_TOLERANCE_M:
|
||||
continue
|
||||
middle = (start_c + end_c) / 2.0
|
||||
if (number > 1 and middle < start_m) or (number < total and middle >= end_m):
|
||||
continue
|
||||
ia = intersection_angle(start, apex, end)
|
||||
blocks.append(
|
||||
{
|
||||
"name": str(index),
|
||||
"x": float(apex[0]),
|
||||
"y": float(apex[1]),
|
||||
"ia": ia,
|
||||
"r": float(radius),
|
||||
**curve_values(float(radius), ia),
|
||||
}
|
||||
)
|
||||
if number == total:
|
||||
blocks.append({"name": "EP", "x": route[-1][1], "y": route[-1][2]})
|
||||
return blocks
|
||||
|
||||
|
||||
def _place(blocks: list[dict[str, Any]], width_mm: float) -> None:
|
||||
"""묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 아래부터 나란히 · 넘치면 위로 다음 줄."""
|
||||
block_w = sum(_COLUMNS)
|
||||
per_line = max(1, int((width_mm - 2 * _MARGIN) // block_w))
|
||||
for order, block in enumerate(blocks):
|
||||
line, column = divmod(order, per_line)
|
||||
top = _MARGIN + line * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H
|
||||
block["origin"] = [_MARGIN + column * block_w, top]
|
||||
block["rows"] = _END_ROWS if "ia" not in block else _IP_ROWS
|
||||
|
||||
|
||||
def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None:
|
||||
"""표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다."""
|
||||
min_x, min_y = box[0], box[1]
|
||||
width = sum(_COLUMNS) / MM
|
||||
area = unary_union(
|
||||
[
|
||||
rect(
|
||||
min_x + b["origin"][0] / MM,
|
||||
min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM,
|
||||
min_x + b["origin"][0] / MM + width,
|
||||
min_y + b["origin"][1] / MM,
|
||||
)
|
||||
for b in blocks
|
||||
]
|
||||
)
|
||||
inside = prep(area)
|
||||
for piece in _CLIPPED:
|
||||
lines = (pieces.get(piece) or {}).get("lines")
|
||||
if not lines:
|
||||
continue
|
||||
kept: list[list[list[float]]] = []
|
||||
for points in lines:
|
||||
if len(points) < 2 or not inside.intersects(LineString(points)):
|
||||
kept.append(points)
|
||||
continue
|
||||
rest = LineString(points).difference(area)
|
||||
parts = rest.geoms if isinstance(rest, MultiLineString) else [rest]
|
||||
kept += [
|
||||
[[round(x, 4), round(y, 4)] for x, y in part.coords]
|
||||
for part in parts
|
||||
if isinstance(part, LineString) and len(part.coords) >= 2
|
||||
]
|
||||
pieces[piece]["lines"] = kept
|
||||
|
||||
|
||||
def ip_table_piece(
|
||||
pieces: dict[str, dict[str, Any]],
|
||||
curves: list[dict[str, Any]],
|
||||
route: list[tuple[float, float, float]],
|
||||
box: tuple[float, float, float, float],
|
||||
chunk: dict[str, Any],
|
||||
total: int,
|
||||
) -> dict[str, Any]:
|
||||
"""그 장 IP 표 조각 {blocks} — 자리를 정하고 같은 자리 밑 선 조각을 `pieces` 에서 잘라 낸다."""
|
||||
blocks = ip_blocks(
|
||||
curves,
|
||||
route,
|
||||
int(chunk["number"]),
|
||||
total,
|
||||
float(chunk["start_m"]),
|
||||
float(chunk["end_m"]),
|
||||
)
|
||||
_place(blocks, (box[2] - box[0]) * MM)
|
||||
if blocks:
|
||||
_clip(pieces, blocks, box)
|
||||
return {"blocks": blocks}
|
||||
|
||||
|
||||
def ip_table_entities(drawing_id: str, data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""묶음마다 표 하나 — 행 IP · CO(X · Y 2행 병합) · IA · R · TL · CL · SL(값 2 · 3열 병합)."""
|
||||
out = []
|
||||
for block in data.get("blocks") or []:
|
||||
cells: list[list[dict[str, Any] | None]] = [
|
||||
[{"text": "I P"}, {"text": block["name"], "colSpan": 2}, None],
|
||||
[{"text": "C O", "rowSpan": 2}, {"text": "X"}, {"text": f"{block['y']:.5f}"}],
|
||||
[None, {"text": "Y"}, {"text": f"{block['x']:.5f}"}],
|
||||
]
|
||||
if "ia" in block:
|
||||
for label, key in _VALUE_ROWS:
|
||||
value = dms(block[key]) if key == "ia" else f"{block[key]:.5f}"
|
||||
cells.append([{"text": label}, {"text": value, "colSpan": 2}, None])
|
||||
out.append(
|
||||
table_entity(
|
||||
f"{drawing_id}:ip_table:{block['name']}",
|
||||
tuple(block["origin"]),
|
||||
list(_COLUMNS),
|
||||
[_ROW_H] * len(cells),
|
||||
cells,
|
||||
LAYER_ID,
|
||||
LINE_COLOR,
|
||||
_FONT_SIZE,
|
||||
TEXT_COLOR,
|
||||
)
|
||||
)
|
||||
return out
|
||||
@@ -1,372 +0,0 @@
|
||||
"""B07 용지도 CAD 조립 — 수치등고선 배경 위에 연속지적도·행정구역을 얹는다.
|
||||
|
||||
사용자 지시(2026-09-04) — 「용지도는 계획평면도와 같이 수치등고선을 배경으로 하고
|
||||
연속지적도·시군구·읍면동을 얹을 것. 색상은 변경하고, 배수유역도의 표 자리에 범례를
|
||||
넣을 것. 연속지적도에 지번 정보가 있는지 확인하고, 없으면 일단 그림만」.
|
||||
|
||||
지번은 있다(2026-09-04 실측: 저장된 연속지적도 GeoJSON 필지마다 `jibun`·`jimok`·
|
||||
`parea`·`owner_nm` + 시도·시군구·읍면동·리 이름). 필지마다 **지번·지목·면적·소유자**
|
||||
네 줄을 적는다(사용자: 「연속지적도에 해당 정보를 띄우고 판단」) — 빈 값은 그 줄만 비운다.
|
||||
|
||||
축척·도곽·장 나눔은 계획평면도와 **같다**(1/1,200 고정). 배경도 같은 창구를 쓴다.
|
||||
|
||||
좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DRAWING_FORMAT,
|
||||
FRAME_LAYER_ID,
|
||||
TABLE_LABEL_COLOR,
|
||||
_layer,
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_COMPASS_MARGIN,
|
||||
_COMPASS_SIZE,
|
||||
_FONT_SIZE,
|
||||
_TITLE_FONT_SIZE,
|
||||
MM,
|
||||
plan_area_mm,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
compass_entities,
|
||||
entities_bbox,
|
||||
frame_entities,
|
||||
scale_fields,
|
||||
)
|
||||
from config.config_system import DRAWING_SCALE_PLAN
|
||||
|
||||
LANDUSE_KIND = "landuse"
|
||||
LANDUSE_LABEL = "용지도"
|
||||
|
||||
CONTOUR_LAYER_ID = "b07-landuse-contour"
|
||||
PARCEL_LAYER_ID = "b07-landuse-parcel"
|
||||
JIBUN_LAYER_ID = "b07-landuse-jibun"
|
||||
EMD_LAYER_ID = "b07-landuse-emd"
|
||||
SGG_LAYER_ID = "b07-landuse-sgg"
|
||||
ROUTE_LAYER_ID = "b07-landuse-route"
|
||||
LEGEND_LAYER_ID = "b07-landuse-legend"
|
||||
TITLE_LAYER_ID = "b07-landuse-title"
|
||||
|
||||
# 도면용 색 — 화면용(유역도)보다 **가라앉힌** 색을 쓴다. 지적 경계가 주제이므로 배경
|
||||
# 등고선은 가장 옅게, 행정 경계는 굵고 진하게 가른다(2026-09-04 사용자 「색상은 변경」).
|
||||
CONTOUR_COLOR = "#9aa3ad"
|
||||
PARCEL_COLOR = "#8c6b4f"
|
||||
JIBUN_COLOR = "#5c4632"
|
||||
EMD_COLOR = "#2f7d4f"
|
||||
SGG_COLOR = "#a63d3d"
|
||||
ROUTE_COLOR = "#ffe066"
|
||||
LEGEND_COLOR = TABLE_LABEL_COLOR
|
||||
|
||||
_ROUTE_WIDTH = 3
|
||||
_SGG_WIDTH = 3
|
||||
_EMD_WIDTH = 2
|
||||
_JIBUN_FONT_SIZE = 1.8
|
||||
_INFO_LINE_GAP = _JIBUN_FONT_SIZE * 1.3 # 필지 정보 줄 간격(mm)
|
||||
_LEGEND_FONT_SIZE = 2.4
|
||||
|
||||
_LEGEND_ROW_H = 6.0
|
||||
_LEGEND_SAMPLE_W = 12.0
|
||||
_LEGEND_GAP = 3.0
|
||||
_LEGEND_TOP_GAP = 8.0
|
||||
|
||||
# 범례 항목 (표기 이름, 색, 선굵기, 파선).
|
||||
_LEGEND_ROWS: tuple[tuple[str, str, int, list[int] | None], ...] = (
|
||||
("계획노선", ROUTE_COLOR, _ROUTE_WIDTH, None),
|
||||
("필지 경계", PARCEL_COLOR, 1, None),
|
||||
("읍면동·리 경계", EMD_COLOR, _EMD_WIDTH, [8, 4]),
|
||||
("시군구 경계", SGG_COLOR, _SGG_WIDTH, [14, 5, 3, 5]),
|
||||
("등고선", CONTOUR_COLOR, 1, None),
|
||||
)
|
||||
|
||||
|
||||
def _ring_center(ring: list[tuple[float, float]]) -> tuple[float, float]:
|
||||
"""고리의 bbox 중심 — 오목한 필지에서도 글자가 도면 밖으로 튀지 않는다."""
|
||||
xs = [x for x, _y in ring]
|
||||
ys = [y for _x, y in ring]
|
||||
return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0)
|
||||
|
||||
|
||||
def _parcel_info_lines(props: dict[str, Any]) -> list[str]:
|
||||
"""지번·지목·면적·소유자 네 줄 — 값이 없으면 그 줄은 빈 문자열."""
|
||||
|
||||
def text(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
area = props.get("parea")
|
||||
try:
|
||||
area_text = f"{float(area):,.0f}㎡" if area not in (None, "") else ""
|
||||
except (TypeError, ValueError):
|
||||
area_text = text(area)
|
||||
return [
|
||||
text(props.get("jibun")),
|
||||
text(props.get("jimok")),
|
||||
area_text,
|
||||
text(props.get("owner_nm")),
|
||||
]
|
||||
|
||||
|
||||
def _enclosing_note(props: dict[str, Any]) -> str:
|
||||
"""구역 전체가 한 필지 안일 때 정보 묶음 맨 위 줄 — 소재지(시군구 읍면동 리)를 붙인다."""
|
||||
place = " ".join(
|
||||
str(props[key]).strip()
|
||||
for key in ("sgg_nm", "emd_nm", "ri_nm")
|
||||
if isinstance(props.get(key), str) and props[key].strip()
|
||||
)
|
||||
return f"구역 전체가 이 필지 안 ({place})" if place else "구역 전체가 이 필지 안"
|
||||
|
||||
|
||||
def _legend_entities(drawing_id: str, origin: tuple[float, float]) -> list[dict[str, Any]]:
|
||||
"""범례 — 유역도에서 유역 정보표가 있던 자리(오른쪽 칸)에 놓는다."""
|
||||
entities: list[dict[str, Any]] = []
|
||||
x, y = origin
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:legend:title",
|
||||
"범 례",
|
||||
x + _LEGEND_SAMPLE_W / 2.0 + 6.0,
|
||||
y,
|
||||
LEGEND_LAYER_ID,
|
||||
_LEGEND_FONT_SIZE + 0.6,
|
||||
LEGEND_COLOR,
|
||||
)
|
||||
)
|
||||
for index, (label, color, width, dash) in enumerate(_LEGEND_ROWS):
|
||||
row_y = y - _LEGEND_TOP_GAP - index * _LEGEND_ROW_H
|
||||
sample = polyline_entity(
|
||||
drawing_id,
|
||||
[(x, row_y), (x + _LEGEND_SAMPLE_W, row_y)],
|
||||
LEGEND_LAYER_ID,
|
||||
color,
|
||||
suffix=f":legend:{index}",
|
||||
dash=dash,
|
||||
width=width,
|
||||
)
|
||||
if sample:
|
||||
entities.append(sample)
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:legend:label:{index}",
|
||||
label,
|
||||
x + _LEGEND_SAMPLE_W + _LEGEND_GAP,
|
||||
row_y,
|
||||
LEGEND_LAYER_ID,
|
||||
_LEGEND_FONT_SIZE,
|
||||
LEGEND_COLOR,
|
||||
align="left",
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
def _boundary_entities(
|
||||
drawing_id: str,
|
||||
rings: list[list[tuple[float, float]]],
|
||||
layer_id: str,
|
||||
color: str,
|
||||
width: int,
|
||||
dash: list[int] | None,
|
||||
paper: Any,
|
||||
tag: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
entities: list[dict[str, Any]] = []
|
||||
for index, ring in enumerate(rings):
|
||||
line = polyline_entity(
|
||||
drawing_id,
|
||||
[paper(point) for point in ring],
|
||||
layer_id,
|
||||
color,
|
||||
suffix=f":{tag}:{index}",
|
||||
dash=dash,
|
||||
width=width,
|
||||
)
|
||||
if line:
|
||||
entities.append(line)
|
||||
return entities
|
||||
|
||||
|
||||
def build_landuse_drawing(
|
||||
drawing_id: str,
|
||||
label: str,
|
||||
route_xy: list[tuple[float, float]],
|
||||
contours: list[list[tuple[float, float]]],
|
||||
parcels: list[dict[str, Any]],
|
||||
emd_rings: list[list[tuple[float, float]]],
|
||||
sgg_rings: list[list[tuple[float, float]]],
|
||||
) -> dict[str, Any]:
|
||||
"""용지도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다.
|
||||
|
||||
`parcels`는 {"ring": [(x, y)...], "props": {지적 속성}} 목록이다. 도곽에 걸친 필지는
|
||||
라우터가 잘라 넘기므로 고리가 아니라 **열린 선**일 수 있다.
|
||||
"""
|
||||
everything = [
|
||||
*route_xy,
|
||||
*(point for line in contours for point in line),
|
||||
*(point for parcel in parcels for point in parcel.get("ring") or []),
|
||||
]
|
||||
if not everything:
|
||||
raise FileNotFoundError(
|
||||
"용지도에 그릴 좌표가 없습니다. B04 전처리에서 연속지적도·수치지형도를 먼저 받으세요."
|
||||
)
|
||||
min_x = min(x for x, _y in everything)
|
||||
min_y = min(y for _x, y in everything)
|
||||
|
||||
def paper(point: tuple[float, float]) -> tuple[float, float]:
|
||||
return ((point[0] - min_x) * MM, (point[1] - min_y) * MM)
|
||||
|
||||
entities: list[dict[str, Any]] = []
|
||||
# 배경 등고선이 가장 아래 — 지적 경계가 주제라 옅게 깐다.
|
||||
entities.extend(
|
||||
_boundary_entities(
|
||||
drawing_id, contours, CONTOUR_LAYER_ID, CONTOUR_COLOR, 1, None, paper, "contour"
|
||||
)
|
||||
)
|
||||
# 필지 경계 + 필지 정보. 도곽에 잘려 조각이 여럿인 필지도 정보는 한 번만 —
|
||||
# 가장 큰 조각 가운데에 적는다(구역 안 필지 수 = 정보 묶음 수).
|
||||
spots: dict[str, tuple[float, tuple[float, float], dict[str, Any]]] = {}
|
||||
enclosing: set[str] = set() # 도곽을 통째로 감싼 필지
|
||||
for index, parcel in enumerate(parcels):
|
||||
ring = parcel.get("ring") or []
|
||||
props = parcel.get("props") or {}
|
||||
label_at = parcel.get("label_at")
|
||||
if len(ring) >= 2:
|
||||
paper_ring = [paper(point) for point in ring]
|
||||
outline = polyline_entity(
|
||||
drawing_id, paper_ring, PARCEL_LAYER_ID, PARCEL_COLOR, suffix=f":parcel:{index}"
|
||||
)
|
||||
if outline:
|
||||
entities.append(outline)
|
||||
width = max(x for x, _y in paper_ring) - min(x for x, _y in paper_ring)
|
||||
height = max(y for _x, y in paper_ring) - min(y for _x, y in paper_ring)
|
||||
size, center = width * height, _ring_center(paper_ring)
|
||||
elif label_at is not None:
|
||||
# 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 정보만 적는다.
|
||||
size, center = 0.0, paper(tuple(label_at))
|
||||
enclosing.add(str(props.get("pnu") or f"#{index}"))
|
||||
else:
|
||||
continue
|
||||
key = str(props.get("pnu") or f"#{index}")
|
||||
if key not in spots or size > spots[key][0]:
|
||||
spots[key] = (size, center, props)
|
||||
# 필지 경계가 하나도 안 보이면(구역이 한 필지 안) 어느 필지인지 글로 밝힌다(68-2).
|
||||
no_outline = not any(len(parcel.get("ring") or []) >= 2 for parcel in parcels)
|
||||
jibun: list[dict[str, Any]] = []
|
||||
for key, (_size, center, props) in spots.items():
|
||||
lines = _parcel_info_lines(props)
|
||||
if no_outline and key in enclosing:
|
||||
lines = [_enclosing_note(props), *lines]
|
||||
top = center[1] + (len(lines) - 1) / 2.0 * _INFO_LINE_GAP
|
||||
for row, text in enumerate(lines):
|
||||
if text:
|
||||
jibun.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:parcel-info:{key}:{row}",
|
||||
text,
|
||||
center[0],
|
||||
top - row * _INFO_LINE_GAP,
|
||||
JIBUN_LAYER_ID,
|
||||
_JIBUN_FONT_SIZE,
|
||||
JIBUN_COLOR,
|
||||
)
|
||||
)
|
||||
# 행정 경계는 필지 위에, 노선은 그 위에 — 아래에 깔리면 필지 선에 묻힌다.
|
||||
entities.extend(
|
||||
_boundary_entities(
|
||||
drawing_id, emd_rings, EMD_LAYER_ID, EMD_COLOR, _EMD_WIDTH, [8, 4], paper, "emd"
|
||||
)
|
||||
)
|
||||
entities.extend(
|
||||
_boundary_entities(
|
||||
drawing_id,
|
||||
sgg_rings,
|
||||
SGG_LAYER_ID,
|
||||
SGG_COLOR,
|
||||
_SGG_WIDTH,
|
||||
[14, 5, 3, 5],
|
||||
paper,
|
||||
"sgg",
|
||||
)
|
||||
)
|
||||
map_bbox = entities_bbox(entities)
|
||||
route = polyline_entity(
|
||||
drawing_id,
|
||||
[paper(point) for point in route_xy],
|
||||
ROUTE_LAYER_ID,
|
||||
ROUTE_COLOR,
|
||||
width=_ROUTE_WIDTH,
|
||||
)
|
||||
if route:
|
||||
entities.append(route)
|
||||
entities.extend(jibun) # 필지 정보는 가장 위 — 선에 가리면 못 읽는다.
|
||||
|
||||
# 오른쪽 칸: 방위표가 맨 위, 그 아래로 범례(유역도에서 유역 정보표가 있던 자리).
|
||||
if map_bbox:
|
||||
column_x = map_bbox[2] + _COMPASS_MARGIN
|
||||
column_top = map_bbox[3]
|
||||
entities.extend(
|
||||
compass_entities(
|
||||
drawing_id,
|
||||
(column_x + _COMPASS_SIZE / 2.0, column_top - _COMPASS_SIZE / 2.0),
|
||||
_COMPASS_SIZE,
|
||||
)
|
||||
)
|
||||
entities.extend(_legend_entities(drawing_id, (column_x, column_top - _COMPASS_SIZE - 10.0)))
|
||||
|
||||
bbox = entities_bbox(entities)
|
||||
if bbox:
|
||||
min_bx, _min_by, max_bx, max_by = bbox
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:title",
|
||||
label,
|
||||
(min_bx + max_bx) / 2.0,
|
||||
max_by + 12.0,
|
||||
TITLE_LAYER_ID,
|
||||
_TITLE_FONT_SIZE,
|
||||
TABLE_LABEL_COLOR,
|
||||
)
|
||||
)
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:scale",
|
||||
f"S = 1/{DRAWING_SCALE_PLAN:,}",
|
||||
max_bx,
|
||||
max_by + 5.0,
|
||||
TITLE_LAYER_ID,
|
||||
_FONT_SIZE,
|
||||
TABLE_LABEL_COLOR,
|
||||
align="right",
|
||||
)
|
||||
)
|
||||
entities.extend(
|
||||
frame_entities(
|
||||
drawing_id,
|
||||
entities_bbox(entities) or bbox,
|
||||
fit=False,
|
||||
fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))},
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
"entities": entities,
|
||||
"layers": [
|
||||
_layer(CONTOUR_LAYER_ID, "등고선", locked=True),
|
||||
_layer(PARCEL_LAYER_ID, "필지 경계"),
|
||||
_layer(JIBUN_LAYER_ID, "필지 정보(지번·지목·면적·소유자)"),
|
||||
_layer(EMD_LAYER_ID, "읍면동·리 경계"),
|
||||
_layer(SGG_LAYER_ID, "시군구 경계"),
|
||||
_layer(ROUTE_LAYER_ID, "계획노선"),
|
||||
_layer(LEGEND_LAYER_ID, "범례"),
|
||||
_layer(TITLE_LAYER_ID, "표제"),
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def landuse_area_mm() -> tuple[float, float]:
|
||||
"""지적 배경이 차지할 수 있는 크기(mm) — 계획평면도와 같다(같은 축척·같은 도곽)."""
|
||||
return plan_area_mm()
|
||||
@@ -179,6 +179,14 @@ def _axis_entities(drawing_id: str, x0: float, min_v: float, max_v: float) -> li
|
||||
return entities
|
||||
|
||||
|
||||
def balloon_size(lines: list[str], font: float = _FONT_SIZE) -> tuple[float, float]:
|
||||
"""balloon (폭, 높이) mm — 글 크기에 비례 · 한글 등 넓은 글자는 1.65 칸."""
|
||||
scale = font / _FONT_SIZE
|
||||
chars = max((sum(1.65 if ord(c) > 0x2E7F else 1.0 for c in line) for line in lines), default=0)
|
||||
width = (chars * _BALLOON_CHAR_W + 2 * _BALLOON_PAD_X) * scale
|
||||
return width, (len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y) * scale
|
||||
|
||||
|
||||
def _balloon_entities(
|
||||
drawing_id: str,
|
||||
seed: str,
|
||||
@@ -186,10 +194,14 @@ def _balloon_entities(
|
||||
anchor: tuple[float, float],
|
||||
center: tuple[float, float],
|
||||
shape: str,
|
||||
layer: str = BAND_LAYER_ID,
|
||||
color: str = BAND_COLOR,
|
||||
font: float = _FONT_SIZE,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""balloon 도형 + 지시선 + 6줄 문자. shape = hexagon | ellipse | rect."""
|
||||
width = max(len(line) for line in lines) * _BALLOON_CHAR_W + 2 * _BALLOON_PAD_X
|
||||
height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y
|
||||
"""balloon 도형 + 지시선 + 여러 줄 문자(빈 줄은 자리만). shape = hexagon | ellipse | rect."""
|
||||
scale = font / _FONT_SIZE
|
||||
width, height = balloon_size(lines, font)
|
||||
line_h, pad_y = _BALLOON_LINE_H * scale, _BALLOON_PAD_Y * scale
|
||||
cx, cy = center
|
||||
half_w, half_h = width / 2.0, height / 2.0
|
||||
if shape == "hexagon":
|
||||
@@ -222,9 +234,7 @@ def _balloon_entities(
|
||||
]
|
||||
|
||||
entities: list[dict[str, Any]] = []
|
||||
shape_entity = polyline_entity(
|
||||
drawing_id, outline, BAND_LAYER_ID, BAND_COLOR, suffix=f":balloon:{seed}"
|
||||
)
|
||||
shape_entity = polyline_entity(drawing_id, outline, layer, color, suffix=f":balloon:{seed}")
|
||||
if shape_entity:
|
||||
entities.append(shape_entity)
|
||||
# 지시선은 **계단형**이고 balloon **모서리**에서 나간다(2026-09-03 사용자 확정 —
|
||||
@@ -234,23 +244,25 @@ def _balloon_entities(
|
||||
leader = polyline_entity(
|
||||
drawing_id,
|
||||
[(corner_x, corner_y), (anchor[0], corner_y), anchor],
|
||||
BAND_LAYER_ID,
|
||||
BAND_COLOR,
|
||||
layer,
|
||||
color,
|
||||
suffix=f":balloon:leader:{seed}",
|
||||
)
|
||||
if leader:
|
||||
entities.append(leader)
|
||||
first_y = cy + half_h - _BALLOON_PAD_Y - _BALLOON_LINE_H * 0.75
|
||||
first_y = cy + half_h - pad_y - line_h * 0.75
|
||||
for index, line in enumerate(lines):
|
||||
if not line:
|
||||
continue
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:balloon:text:{seed}:{index}",
|
||||
line,
|
||||
cx,
|
||||
first_y - index * _BALLOON_LINE_H,
|
||||
BAND_LAYER_ID,
|
||||
_FONT_SIZE,
|
||||
BAND_COLOR,
|
||||
first_y - index * line_h,
|
||||
layer,
|
||||
font,
|
||||
color,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
@@ -21,6 +21,8 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_COMPASS_MARGIN,
|
||||
_COMPASS_SIZE,
|
||||
@@ -54,10 +56,16 @@ PIECES: tuple[tuple[str, str, bool], ...] = (
|
||||
("centerline", "중심선", False),
|
||||
("ip", "IP", False),
|
||||
("radius", "R", False),
|
||||
("ip_table", "IP 표", False),
|
||||
("station", "측점 NO", False),
|
||||
("endpoints", "시종점", False),
|
||||
("refuge", "대피소 · 사토장", False),
|
||||
("drainage", "배수시설 기호", False),
|
||||
# 용지도(93장) — 연속지적도 · 행정구역
|
||||
("parcel", "필지 경계", False),
|
||||
("parcel_info", "필지 정보", False),
|
||||
("emd", "읍면동 경계", False),
|
||||
("sgg", "시군구 경계", False),
|
||||
)
|
||||
PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES)
|
||||
# 옛 조각 id → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다.
|
||||
@@ -73,7 +81,7 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "route",
|
||||
"name": "노선배치도",
|
||||
"pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints"],
|
||||
"pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints", "ip_table"],
|
||||
},
|
||||
{
|
||||
"id": "layout",
|
||||
@@ -86,6 +94,12 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
|
||||
"name": "라이다",
|
||||
"pieces": ["lidar", *[p for p in _TERRAIN if p not in ("contour", "stream")], "station"],
|
||||
},
|
||||
# 용지도 = 옛 따로 도면과 같은 정보(등고선 · 노선 · 지적 · 행정 경계).
|
||||
{
|
||||
"id": "landuse",
|
||||
"name": "용지도",
|
||||
"pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"],
|
||||
},
|
||||
)
|
||||
|
||||
_COLORS = {
|
||||
@@ -102,7 +116,18 @@ _COLORS = {
|
||||
"endpoints": "#ff9d4d",
|
||||
"refuge": "#7ddc7d",
|
||||
"drainage": STRUCTURE_COLOR,
|
||||
"parcel": "#8c6b4f",
|
||||
"parcel_info": "#5c4632",
|
||||
"emd": "#2f7d4f",
|
||||
"sgg": "#a63d3d",
|
||||
}
|
||||
# 행정 · 지적 경계 (선굵기, 파선)
|
||||
_BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = {
|
||||
"parcel": (1, None),
|
||||
"emd": (2, [8, 4]),
|
||||
"sgg": (3, [14, 5, 3, 5]),
|
||||
}
|
||||
_PARCEL_FONT = 1.8
|
||||
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
|
||||
_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수)
|
||||
_LABEL_FONT = 2.5
|
||||
@@ -411,6 +436,8 @@ def _piece_entities(
|
||||
elif piece == "radius":
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT)
|
||||
elif piece == "ip_table":
|
||||
out.extend(ip_table_entities(drawing_id, data))
|
||||
elif piece == "station":
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
cx, cy = paper((item["x"], item["y"]))
|
||||
@@ -475,6 +502,25 @@ def _piece_entities(
|
||||
text(
|
||||
f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left"
|
||||
)
|
||||
elif piece in _BOUNDARY_STYLE:
|
||||
width, dash = _BOUNDARY_STYLE[piece]
|
||||
for index, points in enumerate(data.get("lines") or []):
|
||||
line(points, f":{index}", width=width, dash=dash)
|
||||
elif piece == "parcel_info":
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
out.extend(
|
||||
_balloon_entities(
|
||||
f"{drawing_id}:{piece}",
|
||||
str(index),
|
||||
item["lines"],
|
||||
paper(item["anchor"]),
|
||||
paper(item["center"]),
|
||||
"rect",
|
||||
layer=layer,
|
||||
color=color,
|
||||
font=_PARCEL_FONT,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
CROSS_STANDARD_ID,
|
||||
LANDUSE_ID,
|
||||
MASS_HAUL_ID,
|
||||
WATERSHED_ID,
|
||||
_cross_sheet_plan,
|
||||
@@ -47,7 +46,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
_read_drawing,
|
||||
_read_json,
|
||||
_store_confirmed_drawing,
|
||||
landuse_source,
|
||||
watershed_source,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
|
||||
@@ -355,15 +353,6 @@ async def get_design_drawing(
|
||||
source_design = await asyncio.to_thread(
|
||||
plan_kind_source, context, longitudinal, longitudinal_path, designs, drawing_id
|
||||
)
|
||||
elif LANDUSE_ID.fullmatch(drawing_id):
|
||||
# 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다.
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
|
||||
source_design = await asyncio.to_thread(
|
||||
landuse_source, context, longitudinal, drawing_id
|
||||
)
|
||||
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
|
||||
_read_drawing,
|
||||
project_root,
|
||||
|
||||
@@ -24,16 +24,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import (
|
||||
LANDUSE_LABEL,
|
||||
build_landuse_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
CROSS_SHEET_ID,
|
||||
@@ -56,7 +51,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F401
|
||||
_BASIN_MAX_DISTANCE_M,
|
||||
CONTOUR_FILE,
|
||||
LANDUSE_ID,
|
||||
LIDAR_ID,
|
||||
PLAN_ID,
|
||||
STREAM_FILE,
|
||||
@@ -67,7 +61,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F4
|
||||
_geometry_lines,
|
||||
_too_far_from_route,
|
||||
clip_line_to_box,
|
||||
landuse_source,
|
||||
lidar_source,
|
||||
plan_source,
|
||||
plan_stations,
|
||||
@@ -158,20 +151,6 @@ def _drawing_list(
|
||||
confirmed=bool(manifest_drawings.get(entry["id"], {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
plan_sheets = plan_chunks(plan_stations(longitudinal))
|
||||
# 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다.
|
||||
for chunk in plan_sheets:
|
||||
drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}"
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
id=drawing_id,
|
||||
kind="landuse",
|
||||
label=LANDUSE_LABEL
|
||||
if len(plan_sheets) <= 1
|
||||
else f"{LANDUSE_LABEL} {chunk['number']}장",
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
# 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다).
|
||||
for drawing_id, kind, label in (
|
||||
(COVER_ID, "cover", "표지"),
|
||||
@@ -185,6 +164,12 @@ def _drawing_list(
|
||||
kind=kind,
|
||||
label=label,
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
# 배수유역도는 평면도 묶음 안 항목 — 축척(1/6,000 한 장)이 달라 종류는 아니다.
|
||||
**(
|
||||
{"plan_kind": WATERSHED_ID, "plan_kind_name": "배수유역도"}
|
||||
if drawing_id == WATERSHED_ID
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④.
|
||||
@@ -424,8 +409,6 @@ def _read_drawing(
|
||||
kind = drawing_id # id와 kind가 같은 단장 도면
|
||||
elif PLAN_KIND_ID.fullmatch(drawing_id):
|
||||
kind = "plan"
|
||||
elif LANDUSE_ID.fullmatch(drawing_id):
|
||||
kind = "landuse"
|
||||
else:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
label = str(manifest_entry.get("label") or drawing_id)
|
||||
@@ -478,27 +461,6 @@ def _read_drawing(
|
||||
None,
|
||||
)
|
||||
|
||||
if LANDUSE_ID.fullmatch(drawing_id):
|
||||
# stored_design = landuse_source()가 모아 준 노선·등고선·지적·행정 경계(사업지 CRS).
|
||||
if not isinstance(stored_design, dict):
|
||||
raise FileNotFoundError("용지도 자료가 없습니다.")
|
||||
label = str(stored_design.get("label") or LANDUSE_LABEL)
|
||||
return (
|
||||
"landuse",
|
||||
label,
|
||||
build_landuse_drawing(
|
||||
drawing_id,
|
||||
label,
|
||||
stored_design.get("route_xy") or [],
|
||||
stored_design.get("contours") or [],
|
||||
stored_design.get("parcels") or [],
|
||||
stored_design.get("emd_rings") or [],
|
||||
stored_design.get("sgg_rings") or [],
|
||||
),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if drawing_id == WATERSHED_ID:
|
||||
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
|
||||
if not isinstance(stored_design, dict):
|
||||
|
||||
@@ -16,7 +16,6 @@ from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import LANDUSE_LABEL
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import LIDAR_LABEL, hillshade_png
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
plan_area_mm,
|
||||
@@ -366,14 +365,6 @@ def _plan_structures(context: Any) -> list[dict[str, Any]]:
|
||||
return [point for point in points if isinstance(point, dict)]
|
||||
|
||||
|
||||
LANDUSE_ID = re.compile(r"^landuse(?:_(\d+))?$")
|
||||
|
||||
# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84).
|
||||
PARCEL_FILE = "연속지적도_bounds.geojson"
|
||||
EMD_FILE = "행정구역_읍면동_bounds.geojson"
|
||||
SGG_FILE = "행정구역_시군구_bounds.geojson"
|
||||
|
||||
|
||||
def _clip_rings(
|
||||
path: Path, crs: str, box: tuple[float, float, float, float]
|
||||
) -> list[list[tuple[float, float]]]:
|
||||
@@ -384,112 +375,6 @@ def _clip_rings(
|
||||
return rings
|
||||
|
||||
|
||||
def _contains(ring: list[tuple[float, float]], point: tuple[float, float]) -> bool:
|
||||
"""점이 고리 안에 드는지 (반직선 교차 판정)."""
|
||||
x, y = point
|
||||
inside = False
|
||||
for index in range(len(ring)):
|
||||
x1, y1 = ring[index - 1]
|
||||
x2, y2 = ring[index]
|
||||
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12) + x1:
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
def _clip_parcels(
|
||||
path: Path, crs: str, box: tuple[float, float, float, float]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""연속지적도를 사업지 좌표계로 돌려 도곽 안 필지만 남긴다 (지번 표기용 속성 포함).
|
||||
|
||||
필지는 지번을 적어야 하므로 경계선만 자르는 `_metric_lines` 캐시를 쓰지 못한다 —
|
||||
피처와 속성을 짝지어 읽는다. 도곽 밖 필지는 여기서 버려 도면이 무거워지지 않게 한다.
|
||||
|
||||
두 가지를 함께 낸다.
|
||||
- `ring` : 도곽으로 자른 경계선(밖으로 나가는 부분은 버린다). 자르지 않으면
|
||||
산지 대필지 하나가 도면을 10 km 로 벌린다(2026-09-04 실측: 콘텐츠 8,368 mm).
|
||||
- `label_at` : 도곽을 **통째로 감싸는** 필지의 지번 자리. 임야 대필지 안에 노선이
|
||||
들어앉으면 경계선이 도곽 안에 하나도 없어 지번이 사라진다(2026-09-04 실측:
|
||||
용화_LAS 노선이 「산77-1임 일월면 용화리」 한 필지 안에 통째로 들어감).
|
||||
"""
|
||||
if not path.is_file():
|
||||
return []
|
||||
transformer = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
|
||||
min_x, min_y, max_x, max_y = box
|
||||
center = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0)
|
||||
parcels: list[dict[str, Any]] = []
|
||||
for feature in _geojson_features(path):
|
||||
properties = feature.get("properties") or {}
|
||||
for ring in _geometry_lines(feature.get("geometry")):
|
||||
converted = [
|
||||
(float(x), float(y))
|
||||
for x, y in (transformer.transform(point[0], point[1]) for point in ring)
|
||||
]
|
||||
if len(converted) < 3:
|
||||
continue
|
||||
if max(x for x, _y in converted) < min_x or min(x for x, _y in converted) > max_x:
|
||||
continue
|
||||
if max(y for _x, y in converted) < min_y or min(y for _x, y in converted) > max_y:
|
||||
continue
|
||||
parts = [part for part in clip_line_to_box(converted, box) if len(part) >= 2]
|
||||
for part in parts:
|
||||
parcels.append({"ring": part, "props": properties})
|
||||
if not parts and _contains(converted, center):
|
||||
parcels.append({"ring": [], "props": properties, "label_at": center})
|
||||
return parcels
|
||||
|
||||
|
||||
def landuse_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]:
|
||||
"""용지도 한 장의 입력(노선·등고선·연속지적도·행정구역)을 사업지 CRS(m)로 모은다.
|
||||
|
||||
축척·도곽·장 나눔은 계획평면도와 같다 — 배경도 같은 창구(`map_background`)를 쓴다.
|
||||
"""
|
||||
match = LANDUSE_ID.fullmatch(drawing_id)
|
||||
if not match:
|
||||
raise ValueError("올바르지 않은 용지도 ID입니다.")
|
||||
chunks = plan_chunks(plan_stations(longitudinal))
|
||||
number = int(match.group(1)) if match.group(1) else 1
|
||||
chunk = next((item for item in chunks if item["number"] == number), None)
|
||||
if chunk is None:
|
||||
raise FileNotFoundError("요청한 용지도 장을 찾을 수 없습니다.")
|
||||
total = len(chunks)
|
||||
start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"])
|
||||
|
||||
route_xy = [
|
||||
(vertex.x, vertex.y)
|
||||
for vertex in context.vertices
|
||||
if total <= 1 or start_m <= float(getattr(vertex, "chainage_m", 0.0) or 0.0) <= end_m
|
||||
]
|
||||
background = map_background(
|
||||
Path(context.project_root),
|
||||
context.crs,
|
||||
DRAWING_SCALE_PLAN,
|
||||
plan_area_mm(),
|
||||
route_xy,
|
||||
)
|
||||
# 지적·행정 경계는 등고선과 **같은 범위**로 자른다 — 배경보다 넓으면 도면이 A1을 넘는다.
|
||||
area_w_mm, area_h_mm = plan_area_mm()
|
||||
half_w = area_w_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0
|
||||
half_h = area_h_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0
|
||||
center_x = (min(x for x, _y in route_xy) + max(x for x, _y in route_xy)) / 2.0
|
||||
center_y = (min(y for _x, y in route_xy) + max(y for _x, y in route_xy)) / 2.0
|
||||
box = (
|
||||
min(center_x - half_w, min(x for x, _y in route_xy)),
|
||||
min(center_y - half_h, min(y for _x, y in route_xy)),
|
||||
max(center_x + half_w, max(x for x, _y in route_xy)),
|
||||
max(center_y + half_h, max(y for _x, y in route_xy)),
|
||||
)
|
||||
sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed"
|
||||
label = LANDUSE_LABEL if total <= 1 else f"{LANDUSE_LABEL} {number}장"
|
||||
return {
|
||||
"label": label,
|
||||
"route_xy": route_xy,
|
||||
"contours": background["contours"],
|
||||
"parcels": _clip_parcels(sheet_dir / PARCEL_FILE, context.crs, box),
|
||||
"emd_rings": _clip_rings(sheet_dir / EMD_FILE, context.crs, box),
|
||||
"sgg_rings": _clip_rings(sheet_dir / SGG_FILE, context.crs, box),
|
||||
}
|
||||
|
||||
|
||||
LIDAR_ID = re.compile(r"^plan_lidar(?:_(\d+))?$")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""B07 용지도 조각 — 연속지적도 · 행정구역을 평면도 장 범위로 자르고 필지 라벨 자리를 잡는다(93장).
|
||||
|
||||
조각 넷 = parcel(필지 경계) · parcel_info(필지 정보 상자) · emd(읍면동 경계) · sgg(시군구 경계).
|
||||
필지마다 지번 · 지목 · 면적 · 소유자 네 줄을 상자 + 지시선으로 적는다(빈 값은 그 줄만 비움).
|
||||
지시선 끝 = (필지 ∩ 도곽) − 노선 버퍼 의 대표점 · 상자 = 후보 자리 중 노선 버퍼 · 이미 놓은
|
||||
상자 · 도곽 밖과 안 겹치는 첫 자리. 좌표는 사업지 m — 그리기는 `Engine_Cad_PlanKinds`.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import shapely
|
||||
from pyproj import Transformer
|
||||
from shapely.errors import GEOSException
|
||||
from shapely.geometry import LineString, Polygon, shape
|
||||
from shapely.geometry import box as box_polygon
|
||||
from shapely.validation import make_valid
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_rings, _geojson_features
|
||||
|
||||
LANDUSE_PIECES = ("parcel", "parcel_info", "emd", "sgg")
|
||||
|
||||
# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84).
|
||||
PARCEL_FILE = "연속지적도_bounds.geojson"
|
||||
EMD_FILE = "행정구역_읍면동_bounds.geojson"
|
||||
SGG_FILE = "행정구역_시군구_bounds.geojson"
|
||||
|
||||
PARCEL_FONT = 1.8 # 필지 정보 글 크기(종이 mm)
|
||||
ROUTE_CLEAR_M = 5.0 # 라벨이 비켜 갈 노선 버퍼(중심선에서 m)
|
||||
_GAP_M = 2.0 # 지시선 끝과 상자 사이 틈(m)
|
||||
_RINGS = 5 # 후보 자리 고리 수
|
||||
_DIRECTIONS = ((1, 1), (-1, 1), (1, -1), (-1, -1), (1, 0), (-1, 0), (0, 1), (0, -1))
|
||||
|
||||
|
||||
def parcel_info_lines(props: dict[str, Any]) -> list[str]:
|
||||
"""지번·지목·면적·소유자 네 줄 — 값이 없으면 그 줄은 빈 문자열."""
|
||||
|
||||
def text(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
area = props.get("parea")
|
||||
try:
|
||||
area_text = f"{float(area):,.0f}㎡" if area not in (None, "") else ""
|
||||
except (TypeError, ValueError):
|
||||
area_text = text(area)
|
||||
return [
|
||||
text(props.get("jibun")),
|
||||
text(props.get("jimok")),
|
||||
area_text,
|
||||
text(props.get("owner_nm")),
|
||||
]
|
||||
|
||||
|
||||
def _enclosing_note(props: dict[str, Any]) -> str:
|
||||
"""구역 전체가 한 필지 안일 때 정보 맨 위 줄 — 소재지(시군구 읍면동 리)를 붙인다."""
|
||||
place = " ".join(
|
||||
str(props[key]).strip()
|
||||
for key in ("sgg_nm", "emd_nm", "ri_nm")
|
||||
if isinstance(props.get(key), str) and props[key].strip()
|
||||
)
|
||||
return f"구역 전체가 이 필지 안 ({place})" if place else "구역 전체가 이 필지 안"
|
||||
|
||||
|
||||
def _lines_of(geometry: Any) -> list[list[list[float]]]:
|
||||
return [
|
||||
[[round(x, 4), round(y, 4)] for x, y in part.coords]
|
||||
for part in shapely.get_parts(geometry)
|
||||
if isinstance(part, LineString) and len(part.coords) >= 2
|
||||
]
|
||||
|
||||
|
||||
def clip_parcels(
|
||||
path: Path, crs: str, box: tuple[float, float, float, float]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""도곽에 걸친 필지 [{key, props, area(필지 ∩ 도곽), whole(도곽을 통째로 감쌈), lines}].
|
||||
|
||||
`lines` = 도곽 안 경계선(밖은 버림 — 산지 대필지 하나가 도면을 10 km 로 벌리지 않게).
|
||||
같은 지번(pnu)이 여러 피처면 하나로 합친다.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return []
|
||||
to_site = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
|
||||
|
||||
def project(coords: np.ndarray) -> np.ndarray:
|
||||
xs, ys = to_site.transform(coords[:, 0], coords[:, 1])
|
||||
return np.column_stack([xs, ys])
|
||||
|
||||
frame = box_polygon(*box)
|
||||
groups: dict[str, dict[str, Any]] = {}
|
||||
for index, feature in enumerate(_geojson_features(path)):
|
||||
try:
|
||||
geometry = make_valid(shapely.transform(shape(feature.get("geometry")), project))
|
||||
except (AttributeError, TypeError, ValueError, GEOSException):
|
||||
continue
|
||||
if geometry.is_empty or not geometry.intersects(frame):
|
||||
continue
|
||||
area = geometry.intersection(frame)
|
||||
if area.area <= 0:
|
||||
continue
|
||||
props = feature.get("properties") or {}
|
||||
key = str(props.get("pnu") or f"#{index}")
|
||||
group = groups.setdefault(
|
||||
key, {"key": key, "props": props, "parts": [], "whole": False, "lines": []}
|
||||
)
|
||||
group["parts"].append(area)
|
||||
group["whole"] = group["whole"] or geometry.contains(frame)
|
||||
group["lines"].extend(_lines_of(geometry.boundary.intersection(frame)))
|
||||
for group in groups.values():
|
||||
group["area"] = shapely.union_all(group.pop("parts"))
|
||||
return list(groups.values())
|
||||
|
||||
|
||||
def _anchor(area: Any, clear: Any) -> tuple[float, float]:
|
||||
"""지시선 끝 — 노선 버퍼를 뺀 필지 조각 중 가장 큰 것의 대표점(다 덮이면 필지 대표점)."""
|
||||
free = area.difference(clear)
|
||||
polygons = [part for part in shapely.get_parts(free) if isinstance(part, Polygon)]
|
||||
target = max(polygons, key=lambda part: part.area) if polygons else area
|
||||
point = target.representative_point()
|
||||
return (point.x, point.y)
|
||||
|
||||
|
||||
def place_labels(
|
||||
parcels: list[dict[str, Any]],
|
||||
route_xy: list[tuple[float, float]],
|
||||
box: tuple[float, float, float, float],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""필지마다 라벨 [{lines, anchor, center}] — 큰 필지부터 자리를 잡는다."""
|
||||
clear = LineString(route_xy).buffer(ROUTE_CLEAR_M) if len(route_xy) >= 2 else Polygon()
|
||||
shapely.prepare(clear)
|
||||
no_outline = not any(parcel["lines"] for parcel in parcels)
|
||||
placed: list[tuple[float, float, float, float]] = []
|
||||
items: list[dict[str, Any]] = []
|
||||
for parcel in sorted(parcels, key=lambda item: -item["area"].area):
|
||||
lines = parcel_info_lines(parcel["props"])
|
||||
if no_outline and parcel["whole"]:
|
||||
lines = [_enclosing_note(parcel["props"]), *lines]
|
||||
if not any(lines):
|
||||
continue
|
||||
width_mm, height_mm = balloon_size(lines, PARCEL_FONT)
|
||||
half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0
|
||||
ax, ay = _anchor(parcel["area"], clear)
|
||||
free_of_route: tuple[float, float, float, float] | None = None
|
||||
chosen = None
|
||||
for ring in range(1, _RINGS + 1):
|
||||
for sx, sy in _DIRECTIONS:
|
||||
cx = ax + sx * (half_w + _GAP_M) * ring
|
||||
cy = ay + sy * (half_h + _GAP_M) * ring
|
||||
rect = (cx - half_w, cy - half_h, cx + half_w, cy + half_h)
|
||||
inside = (
|
||||
rect[0] >= box[0]
|
||||
and rect[1] >= box[1]
|
||||
and rect[2] <= box[2]
|
||||
and rect[3] <= box[3]
|
||||
)
|
||||
if not inside or clear.intersects(box_polygon(*rect)):
|
||||
continue
|
||||
free_of_route = free_of_route or rect
|
||||
if not any(
|
||||
rect[0] < other[2]
|
||||
and other[0] < rect[2]
|
||||
and rect[1] < other[3]
|
||||
and other[1] < rect[3]
|
||||
for other in placed
|
||||
):
|
||||
chosen = rect
|
||||
break
|
||||
if chosen:
|
||||
break
|
||||
# ponytail: 빈자리가 없으면 상자끼리 겹침은 받아들임 · 노선과는 끝내 안 겹침(못 놓으면 뺌).
|
||||
rect = chosen or free_of_route
|
||||
if rect is None:
|
||||
continue
|
||||
placed.append(rect)
|
||||
items.append(
|
||||
{
|
||||
"lines": lines,
|
||||
"anchor": [round(ax, 4), round(ay, 4)],
|
||||
"center": [
|
||||
round((rect[0] + rect[2]) / 2.0, 4),
|
||||
round((rect[1] + rect[3]) / 2.0, 4),
|
||||
],
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def landuse_pieces(
|
||||
project_root: Path,
|
||||
crs: str,
|
||||
box: tuple[float, float, float, float],
|
||||
route_xy: list[tuple[float, float]],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""그 장의 용지 조각 넷 — 노선은 라벨이 비켜 갈 전체 중심선(사업지 m)."""
|
||||
folder = project_root / "B04_PreProcess" / "processed"
|
||||
parcels = clip_parcels(folder / PARCEL_FILE, crs, box)
|
||||
return {
|
||||
"parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]},
|
||||
"parcel_info": {"items": place_labels(parcels, route_xy, box)},
|
||||
"emd": {"lines": _clip_rings(folder / EMD_FILE, crs, box)},
|
||||
"sgg": {"lines": _clip_rings(folder / SGG_FILE, crs, box)},
|
||||
}
|
||||
@@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_piece
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_structure_label,
|
||||
plan_area_mm,
|
||||
@@ -53,6 +54,10 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_read_manifest,
|
||||
_write_manifest,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
|
||||
LANDUSE_PIECES,
|
||||
landuse_pieces,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import DRAWING_SCALE_PLAN
|
||||
@@ -65,7 +70,7 @@ _REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄
|
||||
|
||||
|
||||
def plan_title(name: str) -> str:
|
||||
return f"계획평면도({name})"
|
||||
return f"평면도({name})"
|
||||
|
||||
|
||||
# ── 종류 ─────────────────────────────────────────────
|
||||
@@ -75,11 +80,38 @@ def _kinds_dir(project_root: Path) -> Path:
|
||||
return _design_root(project_root) / "plan_kinds"
|
||||
|
||||
|
||||
# 이관 표시 — 있으면 용지도 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음).
|
||||
_LANDUSE_MARK = "_landuse_added"
|
||||
|
||||
|
||||
def _add_landuse_once(folder: Path) -> None:
|
||||
"""93장 앞에 종류 파일을 내린 프로젝트 — 기본 종류 「용지도」를 한 번 더한다."""
|
||||
mark = folder / _LANDUSE_MARK
|
||||
if mark.exists():
|
||||
return
|
||||
landuse = next(kind for kind in DEFAULT_KINDS if kind["id"] == "landuse")
|
||||
documents = []
|
||||
for path in folder.glob("*.json"):
|
||||
try:
|
||||
documents.append(_read_json(path))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
names = {str(document.get("name")) for document in documents}
|
||||
if not (folder / "landuse.json").exists() and landuse["name"] not in names:
|
||||
order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1
|
||||
atomic_write_json(
|
||||
folder / "landuse.json",
|
||||
{"name": landuse["name"], "pieces": list(landuse["pieces"]), "order": order},
|
||||
)
|
||||
mark.touch()
|
||||
|
||||
|
||||
def list_kinds(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 넷."""
|
||||
"""종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 종류."""
|
||||
folder = _kinds_dir(project_root)
|
||||
if not folder.is_dir():
|
||||
return [{**kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS]
|
||||
_add_landuse_once(folder)
|
||||
rows: list[tuple[int, str, dict[str, Any]]] = []
|
||||
for path in folder.glob("*.json"):
|
||||
if not KIND_ID.fullmatch(path.stem):
|
||||
@@ -112,6 +144,7 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]:
|
||||
folder / f"{kind['id']}.json",
|
||||
{"name": kind["name"], "pieces": kind["pieces"], "order": order},
|
||||
)
|
||||
(folder / _LANDUSE_MARK).touch()
|
||||
return kinds
|
||||
|
||||
|
||||
@@ -411,6 +444,15 @@ def build_pieces(
|
||||
pieces["lidar"] = {"image": image, "box": list(image_box)}
|
||||
except (FileNotFoundError, ValueError, OSError):
|
||||
pieces["lidar"] = {"image": None, "box": None}
|
||||
if set(LANDUSE_PIECES) & set(wanted):
|
||||
# 라벨은 이 장 구간이 아니라 노선 전체를 비켜 간다 — 도곽 안에 다른 구간이 들 수 있다.
|
||||
pieces.update(
|
||||
landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route])
|
||||
)
|
||||
if "ip_table" in wanted:
|
||||
pieces["ip_table"] = ip_table_piece(
|
||||
pieces, _load_design_curves(project_root), route, box, chunk, total
|
||||
)
|
||||
|
||||
folder = _design_root(project_root) / "plan_pieces" / str(number)
|
||||
for piece, data in pieces.items():
|
||||
|
||||
@@ -29,14 +29,12 @@ export const DRAWING_GROUPS: readonly {
|
||||
idPrefix?: string;
|
||||
}[] = [
|
||||
{ label: "표지", kind: "cover" },
|
||||
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)를 한 묶음으로 — PLAN 60장.
|
||||
{ label: "계획평면도", kind: "plan" },
|
||||
{ label: "종단면도", kind: "longitudinal" },
|
||||
{ label: "표준 횡단면도", kind: "cross_standard" },
|
||||
{ label: "횡단면도", kind: "cross" },
|
||||
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 사용자 추가) + 배수유역도를 한 묶음으로.
|
||||
{ label: "평면도", kind: "plan" },
|
||||
{ label: "종단도", kind: "longitudinal" },
|
||||
// 표준 횡단면도는 횡단도 묶음 맨 위 한 줄(93-5).
|
||||
{ label: "횡단도", kind: "cross" },
|
||||
{ label: "토적도(유토곡선)", kind: "mass_haul" },
|
||||
{ label: "유역도(배수 유역도)", kind: "watershed" },
|
||||
{ label: "용지도", idPrefix: "landuse" },
|
||||
];
|
||||
|
||||
/** B06 확정 산출물 기반 도면 목록 패널. */
|
||||
@@ -91,7 +89,7 @@ export function buildDrawingSidePanel(
|
||||
for (const group of DRAWING_GROUPS) {
|
||||
if (group.kind === "plan") {
|
||||
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
|
||||
const plans = drawings.filter((item) => item.kind === "plan");
|
||||
const plans = drawings.filter((item) => item.kind === "plan" || item.kind === "watershed");
|
||||
panel.append(buildPlanSection(plans, drawingButton, plan));
|
||||
continue;
|
||||
}
|
||||
@@ -102,8 +100,9 @@ export function buildDrawingSidePanel(
|
||||
(item) => item.id === group.idPrefix || item.id.startsWith(`${group.idPrefix}_`),
|
||||
)
|
||||
: drawings.filter((item) => item.id === group.blankId);
|
||||
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
|
||||
if (items.length <= 1) {
|
||||
const cross = group.kind === "cross";
|
||||
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다 — 횡단도는 늘 묶음.
|
||||
if (items.length <= 1 && !cross) {
|
||||
const [drawing] = items;
|
||||
// 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다.
|
||||
if (!drawing) {
|
||||
@@ -135,6 +134,11 @@ export function buildDrawingSidePanel(
|
||||
sectionTitle.className = "ui-collapsible__title";
|
||||
sectionTitle.textContent = `${group.label} ${items.length}`;
|
||||
section.append(sectionTitle);
|
||||
if (cross) {
|
||||
// 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만.
|
||||
for (const standard of drawings.filter((item) => item.kind === "cross_standard"))
|
||||
section.append(drawingButton(standard, standard.label));
|
||||
}
|
||||
for (const drawing of items) {
|
||||
// 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다.
|
||||
section.append(drawingButton(drawing, drawing.label));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_UI_PlanKinds.ts
|
||||
* 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
|
||||
* 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
|
||||
* 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장).
|
||||
*
|
||||
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
|
||||
@@ -47,7 +47,7 @@ async function run(action: () => Promise<unknown>, controls: PlanKindsControls):
|
||||
await action();
|
||||
controls.onChanged();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "계획평면도를 고치지 못했습니다.", "error");
|
||||
showToast(error instanceof Error ? error.message : "평면도를 고치지 못했습니다.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem
|
||||
return form;
|
||||
}
|
||||
|
||||
/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */
|
||||
/** 「평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */
|
||||
export function buildPlanSection(
|
||||
items: DesignDrawingItem[],
|
||||
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
||||
@@ -156,7 +156,7 @@ export function buildPlanSection(
|
||||
): HTMLElement {
|
||||
const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section");
|
||||
section.dataset.kind = "plan";
|
||||
section.append(el("h3", "ui-collapsible__title", `계획평면도 ${items.length}`));
|
||||
section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`));
|
||||
|
||||
const kinds = new Map<string, DesignDrawingItem[]>();
|
||||
for (const item of items) {
|
||||
@@ -173,7 +173,8 @@ export function buildPlanSection(
|
||||
makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name),
|
||||
);
|
||||
head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]);
|
||||
if (controls) {
|
||||
// × 는 종류만 — 배수유역도는 묶음 안 고정 항목(지우면 서버가 404).
|
||||
if (controls && sheets[0].kind === "plan") {
|
||||
const remove = el("button", "b07-plan-tools__remove", "×");
|
||||
remove.type = "button";
|
||||
remove.title = `「${name}」 종류 지우기`;
|
||||
@@ -192,7 +193,7 @@ export function buildPlanSection(
|
||||
// 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
|
||||
const tools = el("details", "b07-plan-tools");
|
||||
tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리"));
|
||||
tools.addEventListener("toggle", () => {
|
||||
const load = (): void => {
|
||||
if (!tools.open || tools.dataset.loaded) return;
|
||||
tools.dataset.loaded = "true";
|
||||
void fetchPlanKinds(controls.projectId)
|
||||
@@ -204,7 +205,11 @@ export function buildPlanSection(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
tools.addEventListener("toggle", load);
|
||||
// 기본 펼침 — 종류마다 얹을 정보(사용자 지정)가 바로 보이게(93-6).
|
||||
tools.open = true;
|
||||
load();
|
||||
section.append(tools);
|
||||
}
|
||||
return section;
|
||||
|
||||
@@ -80,8 +80,9 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 그리드 제목은 두 열을 가로지른다 */
|
||||
.b07-drawing-group[data-kind="cross"] h3 {
|
||||
/* 그리드 제목 · 표준 횡단면도는 두 열을 가로지른다 */
|
||||
.b07-drawing-group[data-kind="cross"] h3,
|
||||
.b07-drawing-group[data-kind="cross"] [data-drawing-id="cross_standard"] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
|
||||
@@ -176,14 +176,20 @@ export const CANVAS_INPUT_FIELD_HEIGHT = 20;
|
||||
|
||||
/**
|
||||
* Canvas input field background color
|
||||
* 자동색(검정) — 어두운 바탕 검정 칸에 흰 글 · 밝은 바탕 흰 칸에 검정 글(`theme.ts` paintColor)
|
||||
* 토큰 `--cad-chrome-raised` 못 읽을 때 폴백(어두움 값) — 칸 · 글 · 테두리는 토큰 색을 그대로 칠함
|
||||
* (자동색 뒤집기 `theme.ts` paintColor 건너뜀 · `inputController/input-field-draw.ts`)
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#000000';
|
||||
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#251f38';
|
||||
|
||||
/**
|
||||
* Canvas input field text color
|
||||
* Canvas input field border color — 토큰 `--cad-line` 폴백
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_TEXT_COLOR = '#FFF';
|
||||
export const CANVAS_INPUT_FIELD_BORDER_COLOR = '#322a45';
|
||||
|
||||
/**
|
||||
* Canvas input field text color — 토큰 `--cad-text` 폴백
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_TEXT_COLOR = '#e4e0f0';
|
||||
|
||||
/**
|
||||
* Canvas input field background color when text is selected
|
||||
@@ -201,9 +207,9 @@ export const CANVAS_INPUT_FIELD_SELECTION_TEXT_COLOR = '#000';
|
||||
export const CANVAS_INPUT_FIELD_FONT_SIZE = 12;
|
||||
|
||||
/**
|
||||
* Canvas input field instruction text color
|
||||
* Canvas input field instruction text color — 토큰 `--cad-text-dim` 폴백
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR = '#999';
|
||||
export const CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR = '#b3aec6';
|
||||
|
||||
/**
|
||||
* Multiplier to determine the pdf line width from the in application line width
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** 전체 맞춤 계산 — 맞출 개체 고르기 · 배율 · 화면 원점(`screenCanvas.drawController.ts` zoomToFitScreen 이 부름). */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { getEntities, getPickableEntities } from '../state.ts';
|
||||
|
||||
/** M02 가 구조물 도면에 얹는 도각 층 — 서버 `STRUCTURE_FRAME_LAYER` 와 같음 */
|
||||
const STRUCTURE_FRAME_LAYER_ID = 'm02-structure-frame';
|
||||
|
||||
/** 맞출 개체 */
|
||||
export function fitEntities(): Entity[] {
|
||||
// 도각·원지반 같은 잠금 도면층은 빼고 설계 콘텐츠에만 맞춘다 (2026-08-31 사용자
|
||||
// 확정). 도각까지 넣으면 A1 한 장 전체가 잡혀 화면의 40%가 여백으로 간다.
|
||||
// 구조물 도면(M02·B08)은 얹은 도각(작도 틀)이 있으면 도각 전체에 맞춘다 — 옛 DXF 가 자기 도곽·
|
||||
// 표제 띠를 품어 범위가 크면 도각이 화면 밖으로 밀린다.
|
||||
const structureFrame = getEntities().filter((e) => e.layerId === STRUCTURE_FRAME_LAYER_ID);
|
||||
const pickable = getPickableEntities();
|
||||
return structureFrame.length ? structureFrame : pickable.length ? pickable : getEntities();
|
||||
}
|
||||
|
||||
/** 개체 범위를 화면 가운데에 놓는 배율 · 화면 원점(월드 좌표) */
|
||||
export function fitView(entities: Entity[], canvasSize: Point): { zoom: number; offset: Point } {
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
|
||||
const boundingWidth = boundingBox.maxX - boundingBox.minX;
|
||||
const boundingHeight = boundingBox.maxY - boundingBox.minY;
|
||||
|
||||
// 10% 여백을 남기고 두 축 중 더 빡빡한 쪽에 맞춘다 (종횡비 유지)
|
||||
const FIT_MARGIN = 0.9;
|
||||
const scaleX =
|
||||
boundingWidth > 0 ? (canvasSize.x * FIT_MARGIN) / boundingWidth : Number.POSITIVE_INFINITY;
|
||||
const scaleY =
|
||||
boundingHeight > 0 ? (canvasSize.y * FIT_MARGIN) / boundingHeight : Number.POSITIVE_INFINITY;
|
||||
let zoomLevel = Math.min(scaleX, scaleY);
|
||||
if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) zoomLevel = 1;
|
||||
|
||||
// screen = (world - offset) * zoom 이므로, 도면 중심을 화면 중심에 맞추려면
|
||||
// offset = worldCenter - (화면 절반 픽셀) / zoom
|
||||
const worldCenterX = (boundingBox.minX + boundingBox.maxX) / 2;
|
||||
const worldCenterY = (boundingBox.minY + boundingBox.maxY) / 2;
|
||||
return {
|
||||
zoom: zoomLevel,
|
||||
offset: new Point(
|
||||
worldCenterX - canvasSize.x / 2 / zoomLevel,
|
||||
worldCenterY - canvasSize.y / 2 / zoomLevel
|
||||
),
|
||||
};
|
||||
}
|
||||
+51
-1
@@ -3,6 +3,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import type { Layer } from '../App.types';
|
||||
import { setEntities, setLayers } from '../state';
|
||||
import { drawInputField } from '../inputController/input-field-draw';
|
||||
import { ScreenCanvasDrawController } from './screenCanvas.drawController';
|
||||
|
||||
/** translate·drawImage 인자만 기록하는 최소 2d 컨텍스트. */
|
||||
@@ -12,12 +13,19 @@ function recordingContext() {
|
||||
rotate: [],
|
||||
image: [],
|
||||
};
|
||||
const rects: number[][] = [];
|
||||
const texts: { label: string; at: number[] }[] = [];
|
||||
const context = {
|
||||
translate: (x: number, y: number) => calls.translate.push([x, y]),
|
||||
rotate: (a: number) => calls.rotate.push(a),
|
||||
drawImage: (_img: unknown, ...rest: number[]) => calls.image.push(...rest),
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
fillRect: (...rect: number[]) => rects.push(rect),
|
||||
fillText: (label: string) => texts.push({ label, at: calls.translate.at(-1) ?? [] }),
|
||||
measureText: (label: string) => ({ width: label.length * 7 }),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
return { context, calls };
|
||||
return { context, calls, rects, texts };
|
||||
}
|
||||
|
||||
describe('ScreenCanvasDrawController.drawImage', () => {
|
||||
@@ -71,3 +79,45 @@ describe('ScreenCanvasDrawController.zoomToFitScreen', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('커서 입력창(drawInputField)', () => {
|
||||
it('글 중심이 칸 안 · 긴 줄에 맞춰 칸 폭이 늘고 목록도 바탕 안', () => {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g.document = { documentElement: {} };
|
||||
g.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
||||
const { context, rects, texts } = recordingContext();
|
||||
const controller = new ScreenCanvasDrawController(context);
|
||||
controller.setCanvasSize(new Point(800, 600));
|
||||
controller.setScreenMouseLocation(new Point(100, 300));
|
||||
const lines = ['LINE (L) 선', 'LINETYPE (LT) 선종류 — 아주 긴 이름의 명령 후보 줄'];
|
||||
drawInputField(controller, 'LIN', lines);
|
||||
|
||||
const [border, box, divider] = rects; // [x, 캔버스 y(위 변), w, h]
|
||||
expect(border).toEqual([119, 319, box[2] + 2, box[3] + 2]);
|
||||
expect(box[0]).toBe(120);
|
||||
expect(box[1]).toBe(600 - 280); // 커서 아래 20px
|
||||
expect(box[3]).toBe(20 * 3); // 입력 줄 + 목록 두 줄
|
||||
expect(box[2]).toBe(Math.ceil(lines[1].length * 7) + 8); // 가장 긴 줄 + 여백
|
||||
expect(divider).toEqual([120, 339, box[2], 1]); // 입력 줄 아래 끝 한 줄
|
||||
|
||||
expect(texts.map((text) => text.label)).toEqual(['LIN', ...lines]);
|
||||
texts.forEach((text, row) => {
|
||||
const [x, y] = text.at;
|
||||
expect(x).toBe(124);
|
||||
// 글 중심 = 그 줄 세로 가운데 · 12px 글 전체가 칸 안
|
||||
expect(y).toBe(box[1] + 20 * row + 10);
|
||||
expect(y - 6).toBeGreaterThanOrEqual(box[1]);
|
||||
expect(y + 6).toBeLessThanOrEqual(box[1] + box[3]);
|
||||
});
|
||||
});
|
||||
|
||||
it('빈 입력 · 짧은 글은 최소 폭 150 · 한 줄', () => {
|
||||
const { context, rects } = recordingContext();
|
||||
const controller = new ScreenCanvasDrawController(context);
|
||||
controller.setCanvasSize(new Point(800, 600));
|
||||
controller.setScreenMouseLocation(new Point(100, 300));
|
||||
drawInputField(controller, '', []);
|
||||
expect(rects).toHaveLength(2);
|
||||
expect(rects[1]).toEqual([120, 320, 150, 20]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Point, type Vector } from '@flatten-js/core';
|
||||
import { WHEEL_ZOOM_EXPONENT } from '../App.consts';
|
||||
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
|
||||
import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { mapNumberRange } from '../helpers/map-number-range.ts';
|
||||
import { StateVariable } from '../helpers/undo-stack.ts';
|
||||
import { getEntities, getGridEnabled, getPickableEntities, triggerReactUpdate } from '../state.ts';
|
||||
import { getGridEnabled, triggerReactUpdate } from '../state.ts';
|
||||
import { paintColor, themeColor } from '../theme.ts';
|
||||
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
|
||||
import { fitEntities, fitView } from './fit-view.ts';
|
||||
|
||||
/** 토큰을 못 읽을 때만 쓰는 캔버스 배경 폴백. */
|
||||
const CANVAS_BACKGROUND_FALLBACK = '#111';
|
||||
@@ -31,8 +31,6 @@ const CANVAS_BACKGROUND_FALLBACK = '#111';
|
||||
*
|
||||
* To convert between the 2 coordinate systems, you need the screenOffset and screenScale
|
||||
*/
|
||||
/** M02 가 구조물 도면에 얹는 도각 층 — 서버 `STRUCTURE_FRAME_LAYER` 와 같음 */
|
||||
const STRUCTURE_FRAME_LAYER_ID = 'm02-structure-frame';
|
||||
// Batch-mode stroke decimation: skip chain segments shorter than this (screen px)
|
||||
const BATCH_LOD_PX = 0.5;
|
||||
// Batch-mode text smaller than this (screen px) is unreadable — skip drawing it
|
||||
@@ -150,17 +148,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
* 월드 좌표 offset에 그대로 대입해 중심 배치가 어긋나는 문제가 있었다.)
|
||||
*/
|
||||
public zoomToFitScreen() {
|
||||
// 도각·원지반 같은 잠금 도면층은 빼고 설계 콘텐츠에만 맞춘다 (2026-08-31 사용자
|
||||
// 확정). 도각까지 넣으면 A1 한 장 전체가 잡혀 화면의 40%가 여백으로 간다.
|
||||
// 구조물 도면(M02·B08)은 얹은 도각(작도 틀)이 있으면 도각 전체에 맞춘다 — 옛 DXF 가 자기 도곽·
|
||||
// 표제 띠를 품어 범위가 크면 도각이 화면 밖으로 밀린다.
|
||||
const structureFrame = getEntities().filter((e) => e.layerId === STRUCTURE_FRAME_LAYER_ID);
|
||||
const pickable = getPickableEntities();
|
||||
const entities = structureFrame.length
|
||||
? structureFrame
|
||||
: pickable.length
|
||||
? pickable
|
||||
: getEntities();
|
||||
const entities = fitEntities();
|
||||
if (!entities.length) {
|
||||
// 빈 도면 — 원점(0,0)을 화면 가운데로. 안 옮기면 기준점 십자가 왼쪽 아래 귀퉁이에 잘린다.
|
||||
const canvasSize = this.getCanvasSize();
|
||||
@@ -169,31 +157,9 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
this.viewFitted = true;
|
||||
return;
|
||||
}
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
|
||||
const boundingWidth = boundingBox.maxX - boundingBox.minX;
|
||||
const boundingHeight = boundingBox.maxY - boundingBox.minY;
|
||||
const canvasSize = this.getCanvasSize();
|
||||
|
||||
// 10% 여백을 남기고 두 축 중 더 빡빡한 쪽에 맞춘다 (종횡비 유지)
|
||||
const FIT_MARGIN = 0.9;
|
||||
const scaleX =
|
||||
boundingWidth > 0 ? (canvasSize.x * FIT_MARGIN) / boundingWidth : Number.POSITIVE_INFINITY;
|
||||
const scaleY =
|
||||
boundingHeight > 0 ? (canvasSize.y * FIT_MARGIN) / boundingHeight : Number.POSITIVE_INFINITY;
|
||||
let zoomLevel = Math.min(scaleX, scaleY);
|
||||
if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) zoomLevel = 1;
|
||||
this.setScreenScale(zoomLevel);
|
||||
|
||||
// screen = (world - offset) * zoom 이므로, 도면 중심을 화면 중심에 맞추려면
|
||||
// offset = worldCenter - (화면 절반 픽셀) / zoom
|
||||
const worldCenterX = (boundingBox.minX + boundingBox.maxX) / 2;
|
||||
const worldCenterY = (boundingBox.minY + boundingBox.maxY) / 2;
|
||||
this.setScreenOffset(
|
||||
new Point(
|
||||
worldCenterX - canvasSize.x / 2 / zoomLevel,
|
||||
worldCenterY - canvasSize.y / 2 / zoomLevel
|
||||
)
|
||||
);
|
||||
const { zoom, offset } = fitView(entities, this.getCanvasSize());
|
||||
this.setScreenScale(zoom);
|
||||
this.setScreenOffset(offset);
|
||||
this.viewFitted = true;
|
||||
}
|
||||
|
||||
@@ -559,6 +525,15 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
});
|
||||
}
|
||||
|
||||
/** 화면 글 폭(px) — drawTextScreen 과 같은 글꼴로 잼 */
|
||||
public measureTextScreen(label: string, fontSize: number): number {
|
||||
this.context.save();
|
||||
this.context.font = `${fontSize}px ${DEFAULT_TEXT_OPTIONS.fontFamily}`;
|
||||
const width = this.context.measureText(label).width;
|
||||
this.context.restore();
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw some text at the base location
|
||||
* The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text
|
||||
@@ -577,6 +552,8 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
fontFamily: string;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
/** 색을 그대로 칠함 — 자동색 뒤집기(paintColor) 건너뜀(테마 토큰 색) */
|
||||
rawColor: boolean;
|
||||
}> = {}
|
||||
): void {
|
||||
const opts = {
|
||||
@@ -596,7 +573,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
this.context.rotate(angle);
|
||||
this.context.font = `${opts.italic ? 'italic ' : ''}${opts.bold ? 'bold ' : ''}${opts.fontSize}px ${opts.fontFamily}`;
|
||||
this.context.textAlign = opts.textAlign;
|
||||
this.context.fillStyle = paintColor(opts.textColor);
|
||||
this.context.fillStyle = opts.rawColor ? opts.textColor : paintColor(opts.textColor);
|
||||
this.context.textBaseline = 'middle';
|
||||
this.context.fillText(label, 0, 0);
|
||||
this.context.restore();
|
||||
@@ -673,11 +650,19 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
* @param width
|
||||
* @param height
|
||||
* @param color
|
||||
* @param rawColor 색을 그대로 칠함 — 자동색 뒤집기(paintColor) 건너뜀(테마 토큰 색)
|
||||
*/
|
||||
public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {
|
||||
public fillRectScreen(
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string,
|
||||
rawColor = false
|
||||
) {
|
||||
if (this.batching) this.flushBatch();
|
||||
// TODO see if we need to replace this with a call to fillPolygon
|
||||
this.context.fillStyle = paintColor(color);
|
||||
this.context.fillStyle = rawColor ? color : paintColor(color);
|
||||
// 화면 좌표는 아래에서 위로 재는 규약이다(getCanvasPoint·drawLineScreen과 같다).
|
||||
// yMin은 사각형의 아래 변이므로 캔버스 y로 옮길 때 높이를 함께 빼야 제자리다.
|
||||
// 안 빼서 선택 그립이 선보다 8px 아래에 찍혔고, 줌을 해도 화면 픽셀 어긋남이라
|
||||
|
||||
@@ -2,13 +2,6 @@ import { Point } from '@flatten-js/core';
|
||||
import { compact, round } from 'es-toolkit';
|
||||
import { Actor } from 'xstate';
|
||||
import {
|
||||
CANVAS_INPUT_FIELD_BACKGROUND_COLOR,
|
||||
CANVAS_INPUT_FIELD_FONT_SIZE,
|
||||
CANVAS_INPUT_FIELD_HEIGHT,
|
||||
CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR,
|
||||
CANVAS_INPUT_FIELD_MOUSE_OFFSET,
|
||||
CANVAS_INPUT_FIELD_TEXT_COLOR,
|
||||
CANVAS_INPUT_FIELD_WIDTH,
|
||||
PINCH_ZOOM_EXPONENT,
|
||||
SNAP_POINT_DISTANCE,
|
||||
WHEEL_LINE_PX,
|
||||
@@ -66,6 +59,7 @@ import {
|
||||
type TextInputEvent,
|
||||
} from '../tools/tool.types.ts';
|
||||
import { activeToolAcceptsText, ImeSink, isImeKey } from './ime-sink.ts';
|
||||
import { drawInputField } from './input-field-draw.ts';
|
||||
import {
|
||||
ABSOLUTE_POINT_REGEXP,
|
||||
ABSOLUTE_POLAR_REGEXP,
|
||||
@@ -127,36 +121,7 @@ export class InputController {
|
||||
}
|
||||
|
||||
public draw(drawController: ScreenCanvasDrawController) {
|
||||
const screenMouseLocation = drawController.getScreenMouseLocation();
|
||||
|
||||
// draw input field
|
||||
drawController.fillRectScreen(
|
||||
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET,
|
||||
// fillRectScreen이 아래 변 기준으로 바로잡혔으므로, 칸이 있던 자리를 지키려고
|
||||
// 높이만큼 내려 잡는다.
|
||||
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT,
|
||||
CANVAS_INPUT_FIELD_WIDTH,
|
||||
CANVAS_INPUT_FIELD_HEIGHT,
|
||||
CANVAS_INPUT_FIELD_BACKGROUND_COLOR
|
||||
);
|
||||
this.ime.sync(activeToolAcceptsText());
|
||||
// Draw text in input field (조합 중인 한글도 함께)
|
||||
if (this.text || this.ime.composing) {
|
||||
drawController.drawTextScreen(
|
||||
this.text + this.ime.composing,
|
||||
new Point(
|
||||
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
|
||||
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2
|
||||
),
|
||||
{
|
||||
textAlign: 'left',
|
||||
textColor: CANVAS_INPUT_FIELD_TEXT_COLOR,
|
||||
fontSize: CANVAS_INPUT_FIELD_FONT_SIZE,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const matchingCommands = this.getCommandSuggestions();
|
||||
const toolInstruction = getLastStateInstructions();
|
||||
const texts: string[] = [];
|
||||
if (toolInstruction) {
|
||||
@@ -166,11 +131,10 @@ export class InputController {
|
||||
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
|
||||
texts.push(`${roundedX},${roundedY}`);
|
||||
}
|
||||
if (matchingCommands.length) {
|
||||
// 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO)
|
||||
texts.push(...matchingCommands);
|
||||
}
|
||||
this.drawListBelowInputField(drawController, texts);
|
||||
// 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO)
|
||||
texts.push(...this.getCommandSuggestions());
|
||||
// 입력 글(조합 중인 한글도 함께)
|
||||
drawInputField(drawController, this.text + this.ime.composing, texts);
|
||||
}
|
||||
|
||||
public submitText(value: string) {
|
||||
@@ -661,32 +625,6 @@ export class InputController {
|
||||
type: ActorEvent.ESC,
|
||||
});
|
||||
}
|
||||
|
||||
private drawListBelowInputField(
|
||||
drawController: ScreenCanvasDrawController,
|
||||
texts: string[]
|
||||
): void {
|
||||
const screenMouseLocation = drawController.worldToTarget(
|
||||
drawController.getWorldMouseLocation()
|
||||
);
|
||||
const startY =
|
||||
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT * 2 - 2;
|
||||
const offsetY = CANVAS_INPUT_FIELD_HEIGHT;
|
||||
texts.forEach((text, index) => {
|
||||
drawController.drawTextScreen(
|
||||
text,
|
||||
new Point(
|
||||
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
|
||||
startY - index * offsetY
|
||||
),
|
||||
{
|
||||
textAlign: 'left',
|
||||
textColor: CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR,
|
||||
fontSize: CANVAS_INPUT_FIELD_FONT_SIZE,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 커서 따라다니는 입력창 — 캔버스에 직접 그림(`input-controller.ts` draw 가 부름).
|
||||
* 칸 · 글은 테마 토큰 색을 그대로 칠함(자동색 뒤집기 건너뜀) — 명령줄 후보 상자(`App.css`
|
||||
* `.cad-command-suggestions`)와 같은 색.
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
import {
|
||||
CANVAS_INPUT_FIELD_BACKGROUND_COLOR,
|
||||
CANVAS_INPUT_FIELD_BORDER_COLOR,
|
||||
CANVAS_INPUT_FIELD_FONT_SIZE,
|
||||
CANVAS_INPUT_FIELD_HEIGHT,
|
||||
CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR,
|
||||
CANVAS_INPUT_FIELD_MOUSE_OFFSET,
|
||||
CANVAS_INPUT_FIELD_TEXT_COLOR,
|
||||
CANVAS_INPUT_FIELD_WIDTH,
|
||||
} from '../App.consts.ts';
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
|
||||
import { themeColor } from '../theme.ts';
|
||||
|
||||
/** 입력창 색 — 밝음 = 흰 칸 + 테두리 · 어두움 = 짙은 보라 칸 · 글 = CAD 글 색 */
|
||||
export function inputFieldColors(read: (name: string, fallback: string) => string = themeColor) {
|
||||
return {
|
||||
background: read('--cad-chrome-raised', CANVAS_INPUT_FIELD_BACKGROUND_COLOR),
|
||||
border: read('--cad-line', CANVAS_INPUT_FIELD_BORDER_COLOR),
|
||||
text: read('--cad-text', CANVAS_INPUT_FIELD_TEXT_COLOR),
|
||||
dimText: read('--cad-text-dim', CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR),
|
||||
};
|
||||
}
|
||||
|
||||
/** 글 좌우 여백(px) */
|
||||
const INPUT_FIELD_PADDING = 4;
|
||||
|
||||
/**
|
||||
* 입력 글 칸 + 그 아래 안내 · 좌표 · 명령 후보 줄 — 바탕 한 장(명령줄 후보 상자 꼴).
|
||||
* 줄마다 높이 CANVAS_INPUT_FIELD_HEIGHT · 글 중심 = 줄 세로 가운데 · 폭 = max(최소 폭, 가장 긴 줄 + 여백).
|
||||
*/
|
||||
export function drawInputField(
|
||||
drawController: ScreenCanvasDrawController,
|
||||
input: string,
|
||||
lines: string[]
|
||||
): void {
|
||||
const mouse = drawController.getScreenMouseLocation();
|
||||
const colors = inputFieldColors();
|
||||
const rowHeight = CANVAS_INPUT_FIELD_HEIGHT;
|
||||
const fontSize = CANVAS_INPUT_FIELD_FONT_SIZE;
|
||||
const left = mouse.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET;
|
||||
// 화면 좌표는 위가 + — 칸 위 변이 커서 아래 OFFSET · fillRectScreen 의 y 는 아래 변
|
||||
const top = mouse.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET;
|
||||
const height = rowHeight * (1 + lines.length);
|
||||
const textWidth = Math.max(
|
||||
0,
|
||||
...[input, ...lines].map((text) => drawController.measureTextScreen(text, fontSize))
|
||||
);
|
||||
const width = Math.max(CANVAS_INPUT_FIELD_WIDTH, Math.ceil(textWidth) + INPUT_FIELD_PADDING * 2);
|
||||
drawController.fillRectScreen(
|
||||
left - 1,
|
||||
top - height - 1,
|
||||
width + 2,
|
||||
height + 2,
|
||||
colors.border,
|
||||
true
|
||||
);
|
||||
drawController.fillRectScreen(left, top - height, width, height, colors.background, true);
|
||||
if (lines.length) {
|
||||
// 입력 줄과 목록 사이 가는 줄
|
||||
drawController.fillRectScreen(left, top - rowHeight, width, 1, colors.border, true);
|
||||
}
|
||||
const textOptions = { textAlign: 'left' as const, fontSize, rawColor: true };
|
||||
const rowCenter = (row: number) => top - rowHeight * row - rowHeight / 2;
|
||||
if (input) {
|
||||
drawController.drawTextScreen(input, new Point(left + INPUT_FIELD_PADDING, rowCenter(0)), {
|
||||
...textOptions,
|
||||
textColor: colors.text,
|
||||
});
|
||||
}
|
||||
lines.forEach((line, index) => {
|
||||
drawController.drawTextScreen(
|
||||
line,
|
||||
new Point(left + INPUT_FIELD_PADDING, rowCenter(index + 1)),
|
||||
{
|
||||
...textOptions,
|
||||
textColor: colors.dimText,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 한 곳 (PLAN 62-3 · 73-11 · 73-12).
|
||||
"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 · B05 연결값 한 곳 (PLAN 62-3 · 73-11 · 73-12 · 96).
|
||||
|
||||
임의로 정한 값 — 사용자가 나중에 고침. B05/B06 · 배수유역 값과 잇지 않음(자기 기본값만).
|
||||
표준값(유출계수 · 할증 · 재현기간 · 최소 도달시간)은 B05 배수 설정(config DRAINAGE_*)과 같은 값.
|
||||
측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사 · 관 종류 · n 은 안 이음.
|
||||
근거 후보: docs/raw/조사/2026-10-06_62-1_홍수량_방법.md (별표2 2.0배 · KDS 표 2.7-1 · 실무 견본).
|
||||
"""
|
||||
|
||||
@@ -8,7 +9,14 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_drainage_pipes import carry_indices
|
||||
from config.config_system import FORD_MANNING_N, FORD_SLOPE
|
||||
from config.config_system_design import (
|
||||
DRAINAGE_DESIGN_FLOW_FACTOR,
|
||||
DRAINAGE_DESIGN_RETURN_PERIOD_YR,
|
||||
DRAINAGE_RUNOFF_COEFFICIENT,
|
||||
DRAINAGE_TC_MIN_MINUTES,
|
||||
)
|
||||
|
||||
#: 할증(Qd = Q × 할증) — 2.0 별표2 법정 · 1.5 견본(울진 2공구) · 1.2 구기준
|
||||
FACTORS = (2.0, 1.5, 1.2)
|
||||
@@ -22,15 +30,15 @@ PIPE_KINDS = {"파형강관": 0.024, "흄관": 0.013, "콘크리트관": 0.015}
|
||||
#: 처음 값 = 표준 값만(69-6) · 측점 고유 값(유역면적 · L · H · D · S)은 None(빈칸)
|
||||
#: R24 · 지역은 공사지 자료(B08_Quantity_Hydro_Site) — 자료가 없을 때만 화면 입력
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"factor": 2.0,
|
||||
"runoff": 0.80, # 유출계수 f — KDS 가파른 산지
|
||||
"factor": DRAINAGE_DESIGN_FLOW_FACTOR,
|
||||
"runoff": DRAINAGE_RUNOFF_COEFFICIENT, # 유출계수 f — KDS 가파른 산지
|
||||
"area_km2": None,
|
||||
"length_m": None, # 산정에서 하천시점까지 거리
|
||||
"height_m": None, # 산정에서 하천시점까지 표고차
|
||||
"tc_min_minutes": 5.0, # 최소 도달시간(KDS 2.4)
|
||||
"tc_min_minutes": DRAINAGE_TC_MIN_MINUTES, # 최소 도달시간(KDS 2.4)
|
||||
"r24_mm": None,
|
||||
"region": "",
|
||||
"return_period": 100,
|
||||
"return_period": DRAINAGE_DESIGN_RETURN_PERIOD_YR,
|
||||
"pipe_kind": "파형강관",
|
||||
"n": None, # None = 관 종류 값
|
||||
"diameter_mm": None,
|
||||
@@ -58,15 +66,62 @@ SHEET_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
def station_rows(points: list[Any]) -> list[dict[str, Any]]:
|
||||
"""관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford)."""
|
||||
def _positive(value: Any) -> float | None:
|
||||
"""양수만 — 글 '1000' 도 숫자로 · 0 · 빈 값 · 글자는 None(빈칸)."""
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number > 0 else None
|
||||
|
||||
|
||||
def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""B05 값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션."""
|
||||
values: dict[str, Any] = {}
|
||||
if basin is not None:
|
||||
area_m2 = _positive(basin.get("area_m2"))
|
||||
values["area_km2"] = area_m2 / 1e6 if area_m2 else None
|
||||
values["length_m"] = _positive(basin.get("flow_length_m"))
|
||||
values["height_m"] = _positive(basin.get("relief_m"))
|
||||
if sheet == "pipe":
|
||||
values["diameter_mm"] = _positive(options.get("pipe_diameter_mm"))
|
||||
elif sheet == "box":
|
||||
values["box_width_m"] = _positive(options.get("body_width_m"))
|
||||
values["box_height_m"] = _positive(options.get("body_height_m"))
|
||||
else:
|
||||
values["overflow_width_m"] = _positive(options.get("ford_width_m"))
|
||||
return {k: v for k, v in values.items() if v is not None}
|
||||
|
||||
|
||||
def station_rows(
|
||||
points: list[Any], basins: list[dict[str, Any]] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford).
|
||||
|
||||
basins(B05 세부유역 속성) 를 주면 측점마다 연결값 `linked` · 비교용 `b05`(Qd · T) — 측점 짝은
|
||||
`carry_indices`(±0.05 m) · 안 주면 둘 다 None.
|
||||
"""
|
||||
points = sorted(points, key=lambda p: p.chainage_m)
|
||||
picks = (
|
||||
carry_indices([p.chainage_m for p in points], [float(b["chainage_m"]) for b in basins])
|
||||
if basins is not None
|
||||
else [None] * len(points)
|
||||
)
|
||||
rows = []
|
||||
for p in points:
|
||||
for p, pick in zip(points, picks):
|
||||
sheet, kind = SHEETS.get(p.facility, ("pipe", ""))
|
||||
rows.append(
|
||||
{"chainage_m": p.chainage_m, "facility": p.facility, "kind": kind, "sheet": sheet}
|
||||
)
|
||||
return sorted(rows, key=lambda s: s["chainage_m"])
|
||||
row = {"chainage_m": p.chainage_m, "facility": p.facility, "kind": kind, "sheet": sheet}
|
||||
row["linked"] = row["b05"] = None
|
||||
if basins is not None:
|
||||
basin = basins[pick] if pick is not None else None
|
||||
row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin)
|
||||
if basin is not None:
|
||||
row["b05"] = {
|
||||
"design_flow_m3s": basin.get("design_flow_m3s"),
|
||||
"tc_minutes": basin.get("tc_minutes"),
|
||||
}
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def options() -> dict[str, Any]:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""B08 4탭 홍수량 계산서 — 공사지 지역 · 확률강우량 · 저장 파일 (PLAN 69-5 · 69-6).
|
||||
|
||||
읽기만: 전처리 강우량표(`B04_PreProcess/drainage/rainfall_table.json` · 관측소 확률강우량)
|
||||
· 행정구역(`B04_PreProcess/processed/행정구역_*_bounds.geojson` · WGS84) · 프로젝트 등록 지역(DB).
|
||||
· 행정구역(`B04_PreProcess/processed/행정구역_*_bounds.geojson` · WGS84) · 프로젝트 등록 지역(DB)
|
||||
· B05 세부유역(`B04_PreProcess/drainage/04_detailed_basins.geojson` · B05 [저장] 때 씀 · 96-1).
|
||||
배수유역 계산 코드는 부르지 않음 — 이미 저장된 파일만 읽음.
|
||||
|
||||
쓰기: B08 자기 파일 `B08_Quantity/quantities/hydro_inputs.json` 하나([저장] 때만).
|
||||
@@ -16,7 +17,10 @@ from typing import Any
|
||||
|
||||
from shapely.geometry import Point, shape
|
||||
|
||||
RAINFALL_FILE = Path("B04_PreProcess") / "drainage" / "rainfall_table.json"
|
||||
from config.config_system_design import DRAINAGE_CACHE_DIRNAME, DRAINAGE_DETAIL_FILENAME
|
||||
|
||||
RAINFALL_FILE = Path("B04_PreProcess") / DRAINAGE_CACHE_DIRNAME / "rainfall_table.json"
|
||||
BASINS_FILE = Path("B04_PreProcess") / DRAINAGE_CACHE_DIRNAME / DRAINAGE_DETAIL_FILENAME
|
||||
PROCESSED_DIR = Path("B04_PreProcess") / "processed"
|
||||
EMD_FILE = "행정구역_읍면동_bounds.geojson"
|
||||
SGG_FILE = "행정구역_시군구_bounds.geojson"
|
||||
@@ -105,6 +109,18 @@ def site_r24(root: Path, return_period: float) -> float | None:
|
||||
return _r24_table(_read_json(root / RAINFALL_FILE)).get(int(return_period))
|
||||
|
||||
|
||||
def read_basins(root: Path) -> list[dict[str, Any]]:
|
||||
"""B05 세부유역 속성(kind detail_basin) — 파일이 없으면 빈 목록(연결값은 시설 옵션만)."""
|
||||
basins = []
|
||||
for feature in _features(root / BASINS_FILE):
|
||||
props = feature.get("properties") or {}
|
||||
if props.get("kind") == "detail_basin" and isinstance(
|
||||
props.get("chainage_m"), (int, float)
|
||||
):
|
||||
basins.append(props)
|
||||
return basins
|
||||
|
||||
|
||||
def read_saved(root: Path) -> dict[str, dict[str, Any]]:
|
||||
data = _read_json(root / SAVED_FILE) or {}
|
||||
stations = data.get("stations")
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6 · 73-11 · 73-12).
|
||||
|
||||
GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + 기본값 · 선택지
|
||||
+ 공사지(지역 · 재현기간별 R24) + 저장한 입력
|
||||
GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 연결값
|
||||
+ 기본값 · 선택지 + 공사지(지역 · R24) + 저장본
|
||||
POST /api/projects/{id}/quantity/hydro/calc 화면 입력 → 계산서 값(B08_Quantity_Engine_Hydro)
|
||||
PUT /api/projects/{id}/quantity/hydro [저장] — 측점별 입력을 B08 자기 파일에만 씀
|
||||
|
||||
측점 목록만 관 지점 정본에서 읽음 — 관경 · 관 종류 등 B05/B06 값은 잇지 않음(자기 기본값).
|
||||
B05/B06 · 배수유역 파일에는 쓰지 않음.
|
||||
측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) —
|
||||
화면이 빈칸만 채움 · 하상경사 · 관 종류 · n 은 잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,7 +22,13 @@ from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B08_Quantity.B08_Quantity_Engine_Hydro import compute
|
||||
from B08_Quantity.B08_Quantity_Hydro_Defaults import FACTORS, PIPE_KINDS, options, station_rows
|
||||
from B08_Quantity.B08_Quantity_Hydro_Site import read_saved, site_info, site_r24, write_saved
|
||||
from B08_Quantity.B08_Quantity_Hydro_Site import (
|
||||
read_basins,
|
||||
read_saved,
|
||||
site_info,
|
||||
site_r24,
|
||||
write_saved,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
@@ -75,7 +81,7 @@ async def _project_root(project_id: UUID) -> Path:
|
||||
@router.get("/{project_id}/quantity/hydro")
|
||||
async def hydro_sheet(project_id: UUID) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
stations = station_rows(read_pipe_points_file(pipe_points_path_in(root)))
|
||||
stations = station_rows(read_pipe_points_file(pipe_points_path_in(root)), read_basins(root))
|
||||
project = await get_project_by_id(str(project_id)) or {}
|
||||
return {
|
||||
"stations": stations,
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
|
||||
/** 구조물 · 관 지점 한 건 — 필요한 칸만. 구간형은 기준점(`chainage_m`) · 없으면 시점. */
|
||||
export interface PlacedItem {
|
||||
/** 복사본 폴더를 찾는 구조물 id(관 지점은 `pipe@측점`) — 없으면 복사본 없이 원본 */
|
||||
structure_id?: string | null;
|
||||
chainage_m?: number | null;
|
||||
start_m?: number | null;
|
||||
summary_item?: unknown;
|
||||
@@ -82,6 +84,37 @@ export function summaryColumns(
|
||||
);
|
||||
}
|
||||
|
||||
/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지(복사본 찾기용)까지. */
|
||||
export interface StationCombo {
|
||||
id: string;
|
||||
item: SummaryItem;
|
||||
structureId: string | null;
|
||||
}
|
||||
|
||||
/** 측점(1cm 안 같은 측점)에 놓인 조합들 — 구조물마다 본체 → 그룹 차례 · 같은 구조물의 같은 조합은 한 건. */
|
||||
export function combosAt(things: readonly PlacedItem[], station: number): StationCombo[] {
|
||||
const seen = new Set<string>();
|
||||
const found: StationCombo[] = [];
|
||||
for (const thing of things) {
|
||||
const at = stationOf(thing);
|
||||
if (at === null || Math.round(at * 100) !== Math.round(station * 100)) continue;
|
||||
const own = readSummaryItem(thing.summary_item);
|
||||
for (const item of [
|
||||
...(own ? [own] : []),
|
||||
...Object.values(readGroupItems(thing.group_items)),
|
||||
]) {
|
||||
const structureId = thing.structure_id ?? null;
|
||||
const key = `${structureId}
|
||||
${comboId(item)}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
found.push({ id: comboId(item), item, structureId });
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 머리 첫 줄 — 이어진 같은 구분을 한 칸으로 `[구분, 열 수]`. */
|
||||
export function groupSpans(columns: readonly ComboColumn[]): Array<[string, number]> {
|
||||
const spans: Array<[string, number]> = [];
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
/* B08 2탭 구조물도(PLAN 61-2) — 보조 탭 · 카드 둘(도면 · 면적산출). 색은 테마 변수만. */
|
||||
/* B08 구조물도 모달(PLAN 95-3) — 조합마다 카드 둘(도면 · 면적산출). 색은 테마 변수만. */
|
||||
|
||||
/* M02 에 없는 조합 — 빨간 글은 비어 있는 단추만(고른 단추 바탕 위에선 안 읽힘 · 아래 안내 글이 알림) */
|
||||
.b08-drawing__tabs .is-missing:not(.ui-btn--filled) {
|
||||
/* 큰 창 — 공용 모달 css 는 그대로 두고 자기 class 로 */
|
||||
.ui-modal.b08-sheets {
|
||||
width: min(1320px, 96vw);
|
||||
--modal-max: 94vh;
|
||||
}
|
||||
|
||||
.b08-sheets .ui-modal__body {
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b08-sheets__combo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b08-sheets__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b08-sheets__from {
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* M02 에 없는 조합 안내 — 빨간 글 */
|
||||
.b08-sheets__combo > .ui-general-block__empty {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* M02 카드 칸은 셋(도면 · 일위대가 · 면적산출) — 여기는 둘 · 도면을 넓게 */
|
||||
.b08-drawing__cards .m02-struct {
|
||||
.b08-sheets .m02-struct {
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.b08-drawing__cards .m02-struct {
|
||||
.b08-sheets .m02-struct {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Drawing.ts
|
||||
* 2탭 구조물도(PLAN 61-2) — 집계표 열(저장된 구조물 조합)마다 보조 탭 · 한 장 = 그 조합의
|
||||
* M02 도면(도각 포함 · 보기 전용 CAD) + 면적산출 — M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로.
|
||||
* 구조물 집계표 행 → 구조물도 모달(PLAN 95-3) — 그 측점에 놓인 조합 전부를 세로로 쌓음.
|
||||
* 조합 한 개 = M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로 — 도면(도각 포함 · 보기 전용 CAD ·
|
||||
* 여러 장이면 장 탭) + 면적산출.
|
||||
*
|
||||
* 조합 목록 · 차례 = 집계표와 같음(`B08_Quantity_Summary_Data`)
|
||||
* 읽기만 — 조합 원본 층(`readTemplate`)을 읽고 카드는 [저장] · [수정] 없음 · B05/B06 자료도 읽기만
|
||||
* 카드 · CAD · 스프레드시트는 이 탭을 열 때만 `import()`
|
||||
* 원천 = 그 구조물의 프로젝트 복사본(같은 항목일 때) · 없으면 M02 원본 층 — 읽기만
|
||||
* 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`)
|
||||
* 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B08_Quantity_UI_Drawing.css";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker";
|
||||
import { layerOf, pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data";
|
||||
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
|
||||
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { summaryTooltip } from "../common_util/common_util_summary_item";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { layerOf, type PickerRow } from "@ui/ui_template_item_picker_data";
|
||||
import {
|
||||
comboId,
|
||||
summaryColumns,
|
||||
type ComboColumn,
|
||||
type ComboLabel,
|
||||
} from "./B08_Quantity_Summary_Data";
|
||||
PART_OF,
|
||||
projectItems,
|
||||
sameItem,
|
||||
structureFolder,
|
||||
type ProjectItemInfo,
|
||||
} from "@ui/ui_template_item_picker_project";
|
||||
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import type { ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
|
||||
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
|
||||
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import type { ComboLabel, StationCombo } from "./B08_Quantity_Summary_Data";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -36,94 +37,136 @@ function note(text: string): HTMLElement {
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 집계표와 같은 열 + 열마다 M02 줄(없으면 M02 에 없는 조합). */
|
||||
async function loadColumns(
|
||||
projectId: string,
|
||||
): Promise<{ columns: ComboColumn[]; rows: Map<string, PickerRow> }> {
|
||||
const [structures, pickerRows, pipes] = await Promise.all([
|
||||
fetchStructures(projectId),
|
||||
loadPickerRows(),
|
||||
// 배수 분석 전이면 관이 없음 — 구조물만으로
|
||||
fetchDetailPipePoints(projectId).then(
|
||||
(response) => response.pipe_points,
|
||||
() => [],
|
||||
),
|
||||
]);
|
||||
const rows = new Map(pickerRows.map((row) => [rowId(row), row]));
|
||||
const describe = (item: Parameters<typeof comboId>[0]): ComboLabel | null => {
|
||||
const row = rows.get(comboId(item));
|
||||
if (!row) return null;
|
||||
const [group = "", ...rest] = pathOf(row);
|
||||
return { group, name: rest.join(" ") || group || row.key };
|
||||
};
|
||||
return { columns: summaryColumns([...structures.structures, ...pipes], describe), rows };
|
||||
export interface SheetsRequest {
|
||||
projectId: string;
|
||||
/** 모달 제목에 붙는 측점 글 */
|
||||
station: string;
|
||||
combos: StationCombo[];
|
||||
/** `comboId` → M02 줄 */
|
||||
rows: Map<string, PickerRow>;
|
||||
describe: (item: SummaryItem) => ComboLabel | null;
|
||||
}
|
||||
|
||||
/** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
|
||||
export async function renderStructureSheets(host: HTMLElement, projectId: string): Promise<void> {
|
||||
host.replaceChildren(note(L("B08_Quantity_Drawing_Loading")));
|
||||
try {
|
||||
const [{ columns, rows }, { createItemCards }] = await Promise.all([
|
||||
loadColumns(projectId),
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
|
||||
]);
|
||||
if (!host.isConnected) return; // 그새 다른 탭으로 옮김
|
||||
if (!columns.length) return void host.replaceChildren(note(L("B08_Quantity_Summary_NoCombo")));
|
||||
/** 그 구조물의 복사본 목록 — 프로젝트 복사본을 못 읽으면 원본만(모달은 그대로 열림). */
|
||||
const loadCopies = (projectId: string): Promise<ProjectItemInfo[]> =>
|
||||
projectItems(projectId)
|
||||
.list()
|
||||
.catch(() => []);
|
||||
|
||||
let row: PickerRow | null = null;
|
||||
const source = (): ItemSource => {
|
||||
const layer = row ? layerOf(row) : "system";
|
||||
return {
|
||||
layer,
|
||||
projectId: null,
|
||||
read: (part, column) => readTemplate(layer, part, column, null),
|
||||
};
|
||||
function sourceOf(
|
||||
request: SheetsRequest,
|
||||
combo: StationCombo,
|
||||
row: PickerRow,
|
||||
copies: ProjectItemInfo[],
|
||||
): { source: ItemSource; copy: boolean } {
|
||||
const own = combo.structureId
|
||||
? copies.find((info) => info.폴더 === structureFolder(combo.structureId!))
|
||||
: undefined;
|
||||
if (own && sameItem(own, row)) {
|
||||
const items = projectItems(request.projectId);
|
||||
return {
|
||||
copy: true,
|
||||
source: {
|
||||
layer: "project",
|
||||
projectId: request.projectId,
|
||||
read: async (part) => {
|
||||
const doc = await items.read(own.폴더, PART_OF[part]);
|
||||
if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy"));
|
||||
return { 문서: doc };
|
||||
},
|
||||
},
|
||||
};
|
||||
const cards = createItemCards({
|
||||
source,
|
||||
editable: () => false,
|
||||
onEdit: () => undefined,
|
||||
warnOnFail: () => false, // 못 읽으면 카드에 「없음」
|
||||
});
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "b08-quantity__tabs b08-drawing__tabs";
|
||||
tabs.setAttribute("role", "tablist");
|
||||
const notice = document.createElement("div");
|
||||
const sheet = document.createElement("div");
|
||||
sheet.className = "b08-drawing__cards";
|
||||
sheet.append(cards.root);
|
||||
|
||||
const select = (picked: ComboColumn): void => {
|
||||
tabs.replaceChildren(
|
||||
...columns.map((column) => {
|
||||
const button = createButton({
|
||||
label: column.group ? `${column.group} ${column.name}` : column.name,
|
||||
variant: column === picked ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => column !== picked && select(column),
|
||||
});
|
||||
button.setAttribute("role", "tab");
|
||||
button.setAttribute("aria-selected", String(column === picked));
|
||||
button.dataset.b08Combo = column.id;
|
||||
button.title = summaryTooltip(column.item);
|
||||
if (column.missing) button.classList.add("is-missing");
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
row = rows.get(picked.id) ?? null;
|
||||
if (!row?.열) {
|
||||
notice.replaceChildren(note(L("B08_Quantity_Drawing_Missing")));
|
||||
return void cards.load(null);
|
||||
}
|
||||
notice.replaceChildren(...(row.도번 ? [] : [note(L("B08_Quantity_Drawing_NoDrawing"))]));
|
||||
void cards.load({ id: row.열, label: picked.name });
|
||||
};
|
||||
|
||||
host.replaceChildren(tabs, notice, sheet);
|
||||
select(columns[0]);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
host.replaceChildren(note(`${L("B08_Quantity_Drawing_Failed")} — ${reason}`));
|
||||
}
|
||||
const layer = layerOf(row);
|
||||
return {
|
||||
copy: false,
|
||||
source: {
|
||||
layer,
|
||||
projectId: null,
|
||||
read: (part, column) => readTemplate(layer, part, column, null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 조합 한 칸 — 제목줄(이름 · 원천) + 안내 + 카드. */
|
||||
function comboSection(
|
||||
request: SheetsRequest,
|
||||
combo: StationCombo,
|
||||
copies: ProjectItemInfo[],
|
||||
createItemCards: typeof import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards").createItemCards,
|
||||
handles: ItemCardsHandle[],
|
||||
): HTMLElement {
|
||||
const label = request.describe(combo.item);
|
||||
const name = label
|
||||
? [label.group, label.name].filter(Boolean).join(" ")
|
||||
: combo.item.name || combo.item.key;
|
||||
const row = request.rows.get(combo.id) ?? null;
|
||||
const section = document.createElement("section");
|
||||
section.className = "b08-sheets__combo";
|
||||
section.dataset.b08Combo = combo.id;
|
||||
const title = document.createElement("h4");
|
||||
title.className = "b08-sheets__title";
|
||||
title.textContent = name;
|
||||
title.title = summaryTooltip(combo.item);
|
||||
section.append(title);
|
||||
if (!row?.열) {
|
||||
section.append(note(L("B08_Quantity_Drawing_Missing")));
|
||||
return section;
|
||||
}
|
||||
const picked = sourceOf(request, combo, row, copies);
|
||||
const from = document.createElement("span");
|
||||
from.className = "b08-sheets__from";
|
||||
from.textContent = L(
|
||||
picked.copy ? "B08_Quantity_Sheets_FromCopy" : "B08_Quantity_Sheets_FromMaster",
|
||||
);
|
||||
title.append(from);
|
||||
if (!row.도번) section.append(note(L("B08_Quantity_Drawing_NoDrawing")));
|
||||
const cards = createItemCards({
|
||||
source: () => picked.source,
|
||||
editable: () => false,
|
||||
onEdit: () => undefined,
|
||||
warnOnFail: () => false, // 못 읽으면 카드에 「없음」
|
||||
});
|
||||
handles.push(cards);
|
||||
section.append(cards.root);
|
||||
void cards.load({ id: row.열, label: name });
|
||||
return section;
|
||||
}
|
||||
|
||||
/** 모달을 엶 — 조합이 없으면 안내 한 줄 · 불러오다 실패하면 까닭 한 줄. */
|
||||
export function openStructureSheets(request: SheetsRequest): void {
|
||||
const handles: ItemCardsHandle[] = [];
|
||||
let closed = false;
|
||||
openModal({
|
||||
title: `${request.station} ${L("B08_Quantity_Sheets_Title")}`,
|
||||
closeLabel: L("Common_Btn_Close"),
|
||||
dialogClass: "b08-sheets",
|
||||
beforeClose: () => {
|
||||
closed = true; // 아직 불러오는 중이면 뒤늦게 카드를 만들지 않음
|
||||
handles.forEach((cards) => cards.destroy());
|
||||
return true;
|
||||
},
|
||||
mount: (body) => {
|
||||
if (!request.combos.length) return void body.append(note(L("B08_Quantity_Summary_NoCombo")));
|
||||
body.append(note(L("B08_Quantity_Drawing_Loading")));
|
||||
void (async () => {
|
||||
try {
|
||||
const [{ createItemCards }, copies] = await Promise.all([
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
|
||||
loadCopies(request.projectId),
|
||||
]);
|
||||
if (closed) return;
|
||||
body.replaceChildren(
|
||||
...request.combos.map((combo) =>
|
||||
comboSection(request, combo, copies, createItemCards, handles),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (closed) return;
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
body.replaceChildren(note(`${L("B08_Quantity_Drawing_Failed")} — ${reason}`));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
색은 테마 토큰만(밝음 · 어두움 같이) · 테두리는 입력 · 선택 칸(공용 ui-input · ui-select)만
|
||||
· 굵게 + 붉은 계열은 중요 결과(.is-key)만. */
|
||||
|
||||
/* 오른쪽 「진행단계」 떠 있는 판에 안 가리게 */
|
||||
.b08-hydro__toolbar,
|
||||
.b08-hydro__sheet {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.b08-hydro__toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -27,6 +21,7 @@
|
||||
}
|
||||
|
||||
.b08-hydro__sheet {
|
||||
container-type: inline-size; /* 2열 ↔ 1열 = 창이 아닌 이 칸 폭 기준 */
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -38,12 +33,24 @@
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@container (max-width: 800px) {
|
||||
.b08-hydro__body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 카드 높이 = 화면 따라(B08 다른 표와 같은 calc(100vh − N)) · 넘치면 카드 몸만 스크롤 */
|
||||
.b08-hydro__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 240px);
|
||||
}
|
||||
|
||||
.b08-hydro__card .ui-card__body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.b08-hydro__card .ui-card__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* B08_Quantity_UI_Hydro.ts
|
||||
* 4탭 수리집수면적표 — 배수지점 측점마다 보조 탭 · 한 장 = 홍수량 계산서(PLAN 62-2 · 69-4~6).
|
||||
*
|
||||
* 측점 목록만 저장된 관 지점(pipe_points)에서 · 입력은 이 탭 자기 값(B05/B06 값과 안 이음)
|
||||
* 측점 목록은 저장된 관 지점(pipe_points) · B05 연결값(A · L · H · 관경 · 내공 · 월류 폭 · 96)이 빈칸을 채움
|
||||
* 값 차례 = 기본 < 장 기본 < 연결 < 저장 < 초안 · 연결값과 같은 칸은 null 로 저장(연동 유지)
|
||||
* 박스암거 = 사각 단면 장(73-11) · 세월교 · 물넘이포장 = 월류 높이 장(73-12) · 그 밖 = 관 장
|
||||
* 처음 값 = 표준 값 · 측점 고유 값은 빈칸 · 지역 · R24 = 공사지 자료(서버)
|
||||
* 조작 = 캐시(sessionStorage) · [저장] = B08 자기 파일(PUT …/quantity/hydro) · 뒤 캐시 비움
|
||||
@@ -23,6 +24,7 @@ import { stationInterval } from "./B08_Quantity_UI_Summary";
|
||||
import {
|
||||
buildHydroSheet,
|
||||
type HydroInputs,
|
||||
type HydroLink,
|
||||
type HydroOptions,
|
||||
type HydroResult,
|
||||
} from "./B08_Quantity_UI_Hydro_Sheet";
|
||||
@@ -34,6 +36,9 @@ interface HydroStation {
|
||||
kind: string;
|
||||
/** 계산서 장 — 시설이 정함(서버 station_rows) */
|
||||
sheet: HydroInputs["sheet"];
|
||||
/** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */
|
||||
linked: HydroLink["values"] | null;
|
||||
b05: HydroLink["b05"];
|
||||
}
|
||||
|
||||
type Drafts = Record<string, Partial<HydroInputs>>;
|
||||
@@ -83,14 +88,26 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
||||
}
|
||||
|
||||
let drafts: Drafts = readState<Drafts>("b08-hydro", projectId) ?? {};
|
||||
const linkedOf = (station: HydroStation): Record<string, unknown> => station.linked ?? {};
|
||||
/** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */
|
||||
const layer = (station: HydroStation, values: Partial<HydroInputs> = {}) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))),
|
||||
);
|
||||
const inputsOf = (station: HydroStation): HydroInputs =>
|
||||
({
|
||||
...sheet.defaults,
|
||||
...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]),
|
||||
...sheet.saved[keyOf(station)],
|
||||
...drafts[keyOf(station)],
|
||||
...station.linked,
|
||||
// 초안은 측점 입력 통째 — 있으면 저장본 대신(초안의 null = 연결값으로 되돌림)
|
||||
...layer(station, drafts[keyOf(station)] ?? sheet.saved[keyOf(station)]),
|
||||
sheet: station.sheet,
|
||||
}) as HydroInputs;
|
||||
/** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */
|
||||
const unlinked = (station: HydroStation, inputs: HydroInputs): HydroInputs =>
|
||||
Object.fromEntries(
|
||||
Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]),
|
||||
) as unknown as HydroInputs;
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "b08-quantity__tabs b08-hydro__tabs";
|
||||
@@ -134,13 +151,14 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
||||
inputs,
|
||||
sheet,
|
||||
(next) => {
|
||||
drafts[keyOf(station)] = next;
|
||||
drafts[keyOf(station)] = unlinked(station, next);
|
||||
writeState("b08-hydro", drafts, projectId);
|
||||
// 단면 가정 · 관 종류는 그림 · 식이 바뀜 — 장을 다시 그림 · 그 밖은 값만
|
||||
if (next.section !== inputs.section || next.pipe_kind !== inputs.pipe_kind) draw(next);
|
||||
else void calc(next, view.show);
|
||||
},
|
||||
station.kind,
|
||||
{ values: station.linked ?? {}, b05: station.b05 },
|
||||
);
|
||||
page.replaceChildren(view.root);
|
||||
void calc(inputs, view.show);
|
||||
@@ -166,7 +184,9 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
||||
|
||||
/** [저장] — 측점 전부의 지금 입력을 B08 자기 파일로 · 성공하면 캐시를 비우고 저장본으로 다시 엶 */
|
||||
const saveAll = async (): Promise<void> => {
|
||||
const stations = Object.fromEntries(sheet.stations.map((s) => [keyOf(s), inputsOf(s)]));
|
||||
const stations = Object.fromEntries(
|
||||
sheet.stations.map((s) => [keyOf(s), unlinked(s, inputsOf(s))]),
|
||||
);
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = await request<{ saved: Drafts }>(`/projects/${projectId}/quantity/hydro`, {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 장(sheet) 갈래 — 박스 = 사각 내공 B · H · 수심비(73-11) · 세월교 · 물넘이 = 월류 폭 → 월류 높이(73-12)
|
||||
* 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만
|
||||
* 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「-」.
|
||||
* B05 연결값과 같은 칸은 이름 옆 작은 「B05」(풍선 「B05 분석」) · Qd · T 옆에 B05 값 참고(96-2).
|
||||
*
|
||||
* 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게.
|
||||
* ========================================================================== */
|
||||
@@ -77,6 +78,12 @@ export interface HydroOptions {
|
||||
site: HydroSite;
|
||||
}
|
||||
|
||||
/** B05 배수유역 연결(96) — 칸 값 · 비교용 설계유량 · 도달시간(분) */
|
||||
export interface HydroLink {
|
||||
values: Partial<Record<keyof HydroInputs, number>>;
|
||||
b05: { design_flow_m3s: number | null; tc_minutes: number | null } | null;
|
||||
}
|
||||
|
||||
export interface HydroSheet {
|
||||
root: HTMLElement;
|
||||
/** 계산값 칸을 바꿈 — null 이면 비움 + 까닭 한 줄 */
|
||||
@@ -103,6 +110,14 @@ const DASH = "-";
|
||||
const fmt = (value: number | null | undefined, digits: number, unit = ""): string =>
|
||||
value == null || !Number.isFinite(value) ? DASH : `${value.toFixed(digits)}${unit}`;
|
||||
|
||||
/** 입력 칸이 놓인 줄의 이름 칸 */
|
||||
function sheetLabel(left: HTMLElement, right: HTMLElement, key: string): HTMLElement | null {
|
||||
const input = [left, right]
|
||||
.map((card) => card.querySelector(`input[data-b08-hydro="${key}"]`))
|
||||
.find(Boolean);
|
||||
return input?.closest(".b08-hydro__row")?.querySelector(".b08-hydro__label") ?? null;
|
||||
}
|
||||
|
||||
function node<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className = "",
|
||||
@@ -218,8 +233,15 @@ export function buildHydroSheet(
|
||||
onChange: (next: HydroInputs) => void,
|
||||
/** 시설 이름(박스암거 · 세월교 · 물넘이포장) — 관이면 "" */
|
||||
kind = "",
|
||||
link: HydroLink = { values: {}, b05: null },
|
||||
): HydroSheet {
|
||||
const site = options.site;
|
||||
/** 연결된 칸의 「B05」 표 — 값이 연결값과 같을 때만 보임 */
|
||||
const marks = new Map<Key, HTMLElement>();
|
||||
const markLinked = (key: Key): void => {
|
||||
const mark = marks.get(key);
|
||||
if (mark) mark.hidden = current[key] !== link.values[key];
|
||||
};
|
||||
const out = new Map<string, HTMLElement>();
|
||||
const outCell = (id: string, key = false): HTMLElement => {
|
||||
const cell = node("span", `b08-hydro__out${key ? " is-key" : ""}`, DASH);
|
||||
@@ -229,6 +251,7 @@ export function buildHydroSheet(
|
||||
let current = inputs;
|
||||
const commit = (key: Key, value: unknown): void => {
|
||||
current = { ...current, [key]: value } as HydroInputs;
|
||||
markLinked(key);
|
||||
onChange(current);
|
||||
};
|
||||
const numberInput = (key: Key, placeholder = ""): HTMLInputElement => {
|
||||
@@ -238,6 +261,14 @@ export function buildHydroSheet(
|
||||
input.dataset.b08Hydro = key;
|
||||
const value = inputs[key];
|
||||
input.value = value == null ? "" : String(value);
|
||||
const linked = link.values[key];
|
||||
if (linked != null) {
|
||||
input.title = `B05 분석 ${linked}`;
|
||||
const mark = node("span", "b08-hydro__hint", "B05");
|
||||
mark.title = "B05 분석";
|
||||
mark.dataset.b08HydroLinked = key;
|
||||
marks.set(key, mark);
|
||||
}
|
||||
input.addEventListener("change", () => {
|
||||
// 빈칸은 NULLABLE 만 · 최소 도달시간은 0 까지 · 그 밖은 0 보다 커야 함 — 아니면 되돌림
|
||||
const parsed = input.value === "" ? null : Number(input.value);
|
||||
@@ -283,6 +314,18 @@ export function buildHydroSheet(
|
||||
};
|
||||
const sub = (text: string): HTMLElement => node("h4", "b08-hydro__h2", text);
|
||||
const hint = (text: string): HTMLElement => node("span", "b08-hydro__hint", text);
|
||||
/** B05 분석 값 참고(비교만) — 값이 없으면 칸 없음 */
|
||||
const b05Hint = (
|
||||
key: "design_flow_m3s" | "tc_minutes",
|
||||
text: (value: number) => string,
|
||||
): HTMLElement | undefined => {
|
||||
const value = link.b05?.[key];
|
||||
if (value == null) return undefined;
|
||||
const el = hint(text(value));
|
||||
el.title = "B05 분석";
|
||||
el.dataset.b08HydroB05 = key;
|
||||
return el;
|
||||
};
|
||||
|
||||
// ── 왼쪽 1. 홍수량 계산 ──
|
||||
const factor = select(
|
||||
@@ -332,6 +375,7 @@ export function buildHydroSheet(
|
||||
return label;
|
||||
})(),
|
||||
outCell("design_flow", true),
|
||||
b05Hint("design_flow_m3s", (v) => `B05 ${fmt(v, 2)}`),
|
||||
),
|
||||
row("Q : 홍수량 (㎥/s)", outCell("flow")),
|
||||
row("f : 유출계수", numberInput("runoff")),
|
||||
@@ -339,7 +383,16 @@ export function buildHydroSheet(
|
||||
row("A : 유역면적 (㎢)", numberInput("area_km2", "입력"), outCell("area_ha")),
|
||||
sub("1-2. 홍수 도달시간 — 산지부 (Kirpich)"),
|
||||
formula("T = 0.0663 × L^0.77^ × s^-0.385^ (L : ㎞ · T : hr)"),
|
||||
row("T : 홍수 도달시간 (hr)", outCell("tc_hr"), outCell("tc_note")),
|
||||
row(
|
||||
"T : 홍수 도달시간 (hr)",
|
||||
outCell("tc_hr"),
|
||||
(() => {
|
||||
const note = node("span");
|
||||
const b05 = b05Hint("tc_minutes", (v) => ` B05 ${fmt(v, 1)}분`);
|
||||
note.append(outCell("tc_note"), ...(b05 ? [b05] : []));
|
||||
return note;
|
||||
})(),
|
||||
),
|
||||
row("L : 산정에서 하천시점까지 거리 (m)", numberInput("length_m", "입력")),
|
||||
row("H : 산정에서 하천시점까지 표고차 (m)", numberInput("height_m", "입력")),
|
||||
row("s : 지표면 경사 (H / L)", outCell("slope_ground")),
|
||||
@@ -443,6 +496,13 @@ export function buildHydroSheet(
|
||||
});
|
||||
right.classList.add("b08-hydro__card");
|
||||
|
||||
// 「B05」 표는 그 칸 이름 옆에
|
||||
for (const [key, mark] of marks) {
|
||||
const label = sheetLabel(left, right, key);
|
||||
label?.append(" ", mark);
|
||||
markLinked(key);
|
||||
}
|
||||
|
||||
const status = node("p", "b08-hydro__status");
|
||||
const sheet = node("div", "b08-hydro__sheet");
|
||||
sheet.dataset.b08HydroSheet = stationText;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.b08-quantity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
gap: var(--spacing-8); /* 보조 탭 줄 위 = 아래(.b08-quantity__tabs padding-bottom) */
|
||||
padding: var(--spacing-16) var(--spacing-24); /* M02 본문(.m02-main)과 같은 값 */
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* B08_Quantity_UI_Page.ts
|
||||
* 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61)
|
||||
*
|
||||
* 탭 = 구조물 집계표 · 구조물도 · 토공 · 수리집수면적표 — 차례대로 만듦.
|
||||
* 위 탭 단추 = 공용 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1).
|
||||
* 1탭(구조물 집계표 · 61-1) · 4탭(수리집수면적표 · 홍수량 계산서 62-2) 내용 · 나머지는 빈 탭.
|
||||
* 탭 = 구조물 · 토공 · 수리집수면적표 (PLAN 95-1). 위 탭 단추 = 공용 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1).
|
||||
* 구조물 아래 보조 탭 = 구조물 집계표(61-1) · 총괄자재수량집계표 · 콘크리트소요자재집계표 ·
|
||||
* 자재수량집계표(셋은 아직 빈 탭) — 구조물도는 집계표 행을 눌러 여는 모달(95-3).
|
||||
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -18,7 +18,6 @@ import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { renderStructureSummary } from "./B08_Quantity_UI_Summary";
|
||||
import { renderHydro } from "./B08_Quantity_UI_Hydro";
|
||||
import { renderStructureSheets } from "./B08_Quantity_UI_Drawing";
|
||||
import { renderEarthwork } from "./B08_Quantity_UI_Earthwork";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -26,32 +25,68 @@ function L(key: keyof typeof ui_locales): string {
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: "summary", label: "B08_Quantity_Tab_StructureSummary" },
|
||||
{ id: "sheet", label: "B08_Quantity_Tab_StructureSheet" },
|
||||
{ id: "structure", label: "B08_Quantity_Tab_Structure" },
|
||||
{ id: "earthwork", label: "B08_Quantity_Tab_Earthwork" },
|
||||
{ id: "hydrology", label: "B08_Quantity_Tab_Hydrology" },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
/** 구조물 탭 보조 탭 — 첫째만 내용이 있음. */
|
||||
const SHEETS = [
|
||||
{ id: "summary", label: "B08_Quantity_Tab_StructureSummary" },
|
||||
{ id: "material", label: "B08_Quantity_Tab_MaterialTotal" },
|
||||
{ id: "concrete", label: "B08_Quantity_Tab_ConcreteMaterial" },
|
||||
{ id: "quantity", label: "B08_Quantity_Tab_MaterialQuantity" },
|
||||
] as const;
|
||||
|
||||
type SheetId = (typeof SHEETS)[number]["id"];
|
||||
|
||||
function pendingNote(): HTMLElement {
|
||||
const pending = document.createElement("p");
|
||||
pending.className = "ui-general-block__empty";
|
||||
pending.textContent = L("B08_Quantity_Tab_Pending");
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** 구조물 탭 — 보조 탭 줄 + 몸통(보조 탭마다 새 몸통 · 늦은 응답이 다른 보조 탭을 덮지 않게). */
|
||||
function renderStructure(host: HTMLElement, projectId: string | null): void {
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "b08-quantity__tabs b08-structure__tabs";
|
||||
tabs.setAttribute("role", "tablist");
|
||||
const view = document.createElement("div");
|
||||
const select = (picked: SheetId): void => {
|
||||
tabs.replaceChildren(
|
||||
...SHEETS.map(({ id, label }) => {
|
||||
const button = createButton({
|
||||
label: L(label),
|
||||
variant: id === picked ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => id !== picked && select(id),
|
||||
});
|
||||
button.setAttribute("role", "tab");
|
||||
button.setAttribute("aria-selected", String(id === picked));
|
||||
button.dataset.b08StructureSheet = id;
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
const body = document.createElement("div");
|
||||
view.replaceChildren(body);
|
||||
if (picked === "summary" && projectId) void renderStructureSummary(body, projectId);
|
||||
else body.append(pendingNote());
|
||||
};
|
||||
host.replaceChildren(tabs, view);
|
||||
select("summary");
|
||||
}
|
||||
|
||||
/** 탭마다 새 몸통 — 늦게 온 응답이 다른 탭 화면을 덮지 않게(떨어진 몸통에만 그림). */
|
||||
function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): void {
|
||||
const view = document.createElement("div");
|
||||
host.replaceChildren(view);
|
||||
if (tab === "summary" && projectId) {
|
||||
void renderStructureSummary(view, projectId);
|
||||
return;
|
||||
}
|
||||
if (tab === "hydrology" && projectId) {
|
||||
void renderHydro(view, projectId);
|
||||
return;
|
||||
}
|
||||
if (tab === "sheet" && projectId) return void renderStructureSheets(view, projectId);
|
||||
if (tab === "structure") return renderStructure(view, projectId);
|
||||
if (tab === "hydrology" && projectId) return void renderHydro(view, projectId);
|
||||
if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId);
|
||||
const pending = document.createElement("p");
|
||||
pending.className = "ui-general-block__empty";
|
||||
pending.textContent = L("B08_Quantity_Tab_Pending");
|
||||
view.append(pending);
|
||||
view.append(pendingNote());
|
||||
}
|
||||
|
||||
export function renderB08Quantity(root: HTMLElement): void {
|
||||
@@ -84,7 +119,7 @@ export function renderB08Quantity(root: HTMLElement): void {
|
||||
const body = document.createElement("div");
|
||||
body.className = "b08-quantity";
|
||||
body.append(tabBar, panel);
|
||||
select("summary");
|
||||
select("structure");
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B08_Quantity_Title"),
|
||||
|
||||
@@ -5,23 +5,29 @@
|
||||
*
|
||||
* 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만)
|
||||
* 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1`
|
||||
* 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker";
|
||||
import { pathOf, rowId } from "@ui/ui_template_item_picker_data";
|
||||
import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { summaryTooltip } from "../common_util/common_util_summary_item";
|
||||
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import {
|
||||
combosAt,
|
||||
comboId,
|
||||
summaryColumns,
|
||||
summaryStations,
|
||||
type ComboColumn,
|
||||
type ComboLabel,
|
||||
type PlacedItem,
|
||||
type StationCombo,
|
||||
} from "./B08_Quantity_Summary_Data";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -44,7 +50,12 @@ function note(text: string): HTMLElement {
|
||||
/** 머리 글 길이에 맞춘 열 폭 — 칸은 비어 있어 글이 폭을 정함. */
|
||||
const widthOf = (label: string): number => Math.min(200, Math.max(96, label.length * 13 + 24));
|
||||
|
||||
function buildTable(stations: number[], columns: ComboColumn[], interval: number): HTMLElement {
|
||||
function buildTable(
|
||||
stations: number[],
|
||||
columns: ComboColumn[],
|
||||
interval: number,
|
||||
onRowClick: (station: number, label: string) => void,
|
||||
): HTMLElement {
|
||||
const table = createDataTable({
|
||||
columns: [
|
||||
{ key: "station", label: L("B08_Quantity_Summary_Station"), width: 96 },
|
||||
@@ -59,6 +70,7 @@ function buildTable(stations: number[], columns: ComboColumn[], interval: number
|
||||
],
|
||||
// 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸
|
||||
rows: stations.map((station) => ({ station: stationLabel(station, interval) })),
|
||||
onRowClick: (i) => onRowClick(stations[i], stationLabel(stations[i], interval)),
|
||||
frozen: 1,
|
||||
empty: "",
|
||||
caption: L("B08_Quantity_Tab_StructureSummary"),
|
||||
@@ -67,6 +79,22 @@ function buildTable(stations: number[], columns: ComboColumn[], interval: number
|
||||
return table.root;
|
||||
}
|
||||
|
||||
/** 행 누름 — 카드 · CAD 묶음은 이때 처음 받음. */
|
||||
async function openSheets(
|
||||
projectId: string,
|
||||
station: string,
|
||||
combos: StationCombo[],
|
||||
rows: Map<string, PickerRow>,
|
||||
describe: (item: SummaryItem) => ComboLabel | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing");
|
||||
openStructureSheets({ projectId, station, combos, rows, describe });
|
||||
} catch (error) {
|
||||
showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
|
||||
export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise<void> {
|
||||
host.replaceChildren(note(L("B08_Quantity_Summary_Loading")));
|
||||
@@ -82,20 +110,25 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
stationInterval(projectId).catch(() => 20),
|
||||
]);
|
||||
const m02 = new Map(pickerRows.map((row) => [rowId(row), row]));
|
||||
const describe = (item: Parameters<typeof comboId>[0]): ComboLabel | null => {
|
||||
const describe = (item: SummaryItem): ComboLabel | null => {
|
||||
const row = m02.get(comboId(item));
|
||||
if (!row) return null;
|
||||
const [group = "", ...rest] = pathOf(row);
|
||||
return { group, name: rest.join(" ") || group || row.key };
|
||||
};
|
||||
const things = [...structures.structures, ...pipes];
|
||||
const things: PlacedItem[] = [
|
||||
...structures.structures,
|
||||
...pipes.map((pipe) => ({ ...pipe, structure_id: pipeStructureId(pipe.chainage_m) })),
|
||||
];
|
||||
const stations = summaryStations(things);
|
||||
const columns = summaryColumns(things, describe);
|
||||
const parts: HTMLElement[] = [];
|
||||
if (!columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo")));
|
||||
parts.push(
|
||||
stations.length
|
||||
? buildTable(stations, columns, interval)
|
||||
? buildTable(stations, columns, interval, (station, label) =>
|
||||
openSheets(projectId, label, combosAt(things, station), m02, describe),
|
||||
)
|
||||
: note(L("B08_Quantity_Summary_NoStation")),
|
||||
);
|
||||
host.replaceChildren(...parts);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# 상시 계획서
|
||||
|
||||
**남은 작업만 두는 유일한 계획서.** 목록이 비면 프로젝트 최종 완료.
|
||||
|
||||
81~91장 공통 근거 = `docs/raw/요구_1008/` — 요구 원문 `사용자요구_1008.md` · 정함과 창 사이 약속 `분석_정함.md` · 파일:줄 `조사_A~H_*.md`.
|
||||
|
||||
## 81. 전역 글 선택 · 스프레드시트 우클릭 · 알약 일괄 해제 · 단위 Φ (데스크탑 메인)
|
||||
|
||||
사용자 확정(10-8) — 우클릭은 크롬 스프레드시트 참고 · 알약 줄에 한꺼번에 연결 해제 · 면적산출 단위에 파이.
|
||||
정함(브레인) — 드래그 글 선택 막음(입력칸 · 원문 본문 허용) · 메뉴는 기능 있는 것만(링크 · 댓글 · 보호 안 만듦) · 파이 = Φ 숫자 앞 · 일괄 해제 = 알약 줄 있는 곳 전부(분석_정함 1장).
|
||||
|
||||
- [x] 81-1 B·M 페이지 드래그 글 선택 막음 · 허용 = 입력칸 · 칸 편집기 · 원문 본문(`ui_template` 전역 CSS 한 곳 + 허용 클래스) · 확인 = M02 · B06 화면 끌기 선택 0 · 입력칸 선택 됨
|
||||
- [x] 81-2 우클릭 속도 — 상태줄 통계 쓴 범위만 · 알약 점 읽기/쓰기 분리 · 전체 고름 행 머리 배열(`spreadsheet_statusbar.ts` · `spreadsheet_links.ts` · `spreadsheet_menu.ts`) · 확인 = 열 머리 우클릭 전·뒤 ms 수치 100 ms 안 · Ctrl+A 멈춤 없음
|
||||
검증완료(81-1 · 81-2 · 데스크탑 메인 보고 · 브레인 기록) — typecheck · 시험 51 통과 · ORCA 화면 조작 확인 · 커밋 3f64d7c7 · 49a88678
|
||||
- [x] 81-3 (sub8 이 맡음) 우클릭 메뉴 — 붙여넣기 · 선택하여 붙여넣기 ▸ · 행/열 위아래 · 좌우 삽입 · 셀 삽입/삭제 ▸ · 크기 · 정렬 · 필터 · 고정 · 드롭다운 · 조건부 서식 · 범위 이름 · 그룹 · 구분선 · 읽기 전용 「복사」(`spreadsheet_menu.ts` · 공용 메뉴 구분선) · 확인 = 항목별 시험 · 크기 예산 시험
|
||||
- 검증완료 — test_stage4_menu_items 36 · size 화면 16.4 KB(예산 72) · d_input · typecheck · build:spreadsheet · 화면 M02 면적산출(sub8임시): 칸 · 행 머리 · 열 머리 메뉴 열기 1~8 ms · 행 위/아래 · 열 좌우 · 셀 밀기/당기기 · 행열 삭제 셀 수치 맞음 · 열 B 64→120 px · 맞추기 A 64→47 · 시트 정렬 행째 · 필터 A1:B · 고정 위 칸 0→20 px · 그룹 · 조건부 서식 · 유효성 창 · 범위 이름(같은 이름 거름) · 붙여넣기 · 값만 붙여넣기(localhost) · 메뉴 복사 대상 = 격자 · 미리보기 3곳 「복사」만 · 어두움 구분선 · 하위 펼침 화면 안 · 항목 지움
|
||||
- [x] 81-4 (sub2 가 맡음) 알약 줄 [연결 모두 해제] — 물음 한 번 · 되돌리기 한 번(`spreadsheet_links.ts`) · 확인 = 면적산출 연결 n → 0 · 되돌리기 n
|
||||
검증완료 — test_spreadsheet_set_link 35 통과(모두 해제 묶음 · 되돌리기 한 번 · 연결 없으면 null) · typecheck · build:spreadsheet / ORCA 5177 면적산출 상세: 연결 3 → [연결 모두 해제] 물음 「알약 연결 3개를 모두 풀까?」 → 취소 3 유지 · 해제 0 · 되돌리기 한 번에 3 · 카드 미리보기에도 단추 · 밝음 · 어두움
|
||||
- [x] 81-5 (sub2 가 맡음) 단위 Φ 숫자 앞에 붙임(`spreadsheet_toolbar.ts` · `spreadsheet_text.ts`) · 확인 = 칸 「300」 → 「Φ300」
|
||||
검증완료 — test_spreadsheet_unit_prefix 2 통과(형식 코드 앞 · 뒤 단위) · typecheck / ORCA 5177: 「3」 → 「Φ3」 · 두 번 눌러도 한 번 · 서식으로 = 형식 코드 `"Φ"0` · 편집 중 「300」 → 「Φ300」 · 길이 묶음 끝에 Φ · 낫표 칸 「'300」 → 「'Φ300」(값 Φ300) 시험 추가(unit_prefix 3 · toolbar_unit 15)
|
||||
|
||||
## 82. CAD 공용 탭 · M02 여러 장 도면 (sub6)
|
||||
|
||||
사용자 확정(10-8) — CAD 에서 탭으로 여러 도면 분리 관리 · 구조물 조합 한 도면이 여러 장이면 탭 · 엑셀 → 도면 표 보내기 조심.
|
||||
정함(브레인) — 탭 = CAD 를 감싸는 공용 탭 줄(CAD 안 상태 그대로) · M02 = 한 파일 `도면`(1장) + `더한장[]` · 장 도번 SD-000010-2 · B07 = 목록에서 연 도면 탭(분석_정함 1 · 2장).
|
||||
|
||||
- [x] 82-1 공용 탭 줄 — 고르기 · [+] 새 장 · 이름 · 지우기 · 끌어 순서 · 바꿀 때 지금 장 받아 두고 싣기(새 `ui_template/cad_host/cad_host_tabs.ts`) · 확인 = 탭 셋 왕복 엔티티 수 그대로
|
||||
- 검증완료 — ORCA 5181 시스템 층 sub6임시 · 탭 셋 왕복 7번 엔티티 3·2·1 그대로 · 이름 빈 값 · 같은 이름 막음 · 1장 앞 끌기 막음 · 도번 다시 매김 · 밝음 · 어두움 · test_cad_host_tabs 9
|
||||
- [x] 82-2 M02 구조물 도면 여러 장 — 저장 · 읽기 · 복사 · 스냅샷 · 장 도번 · 도각 `{{도면번호}}`(`M02_MasterTemplete_Drawing.ts` · `Store.py` · `Template_Layers.py`) · 확인 = 둘째 장 저장 · 다시 읽기 · 복사본 장 수
|
||||
- 검증완료 — 저장 1장 3 · 더한장 [상세 1 · 2장 2] · 다시 읽기 같음 · 카드 「장 3」 · 도각 SD-000007-2 · 장 지우기 저장 더한장 1 · 붙여넣기 복사본 장 2 · test_m02_drawing_sheets 12
|
||||
- [x] 82-3 면적 → 도면 표 여러 장 — 놓을 장 = 지금 탭 · T번호 장 전체 · 연결표 `장` · 안 열린 장 갱신 = 서버(`AreaToDrawing.ts` · `M02_Area_Table.py` · `aislo-table-bridge.ts`) · 확인 = 둘째 장 표 값 갱신 수치
|
||||
- 검증완료 — 보낸 표를 둘째 탭에 놓음 · 연결표 T1 장 = 둘째 장id · 면적 B1 100→250 저장 뒤 안 열린 둘째 장 표 250 · 500 · 놓기 전 탭 바꿈 = 그 장에 다시 놓기 · test_m02_area_to_drawing 11
|
||||
- [x] 82-4 B07 — 목록에서 연 도면이 탭 · 닫기 · 전환(`B07_DesignDetail_UI_Page.ts`) · 확인 = 탭 전환 뒤 미저장 편집 안 잃음
|
||||
- 검증완료 — 검증_sub6 종단도(2)에 선 1 · 탭 셋 오가도 그 장만 796 · 미저장 탭 닫기 물음 · 깨끗한 탭은 안 물음 · 다음 = 그 도면 탭 · 마지막 탭 닫기 없음 · 버린 뒤 서버 795
|
||||
|
||||
## 83. 도면 → 엑셀(면적산출) 한 번 옮기기 (데스크탑 보조)
|
||||
|
||||
사용자 확정(10-8) — 연동 아님 · 엑셀 완료 뒤 다시 도면으로 보내야 싱크 · 불러온 도면 정보가 표가 아닐 수 있음.
|
||||
정함(브레인) — 면적산출 상세 도면 패널에서 고른 범위 → 새 시트(「도면에서 n」 · 기존 시트 안 덮음) · 표 엔티티 · 선+글 격자 인식 · 글 자리 폴백 · 지금 장만(분석_정함 1 · 2장).
|
||||
|
||||
- [x] 83-1 고른 범위 → 표 자료(칸 글 · 병합 · 폭 · 높이 · 「1,500」 → 숫자)(새 `openwebcad/src/helpers/table-from-drawing.ts`) · 확인 = 견본 셋(표 · 선 표 · 글만) 칸 수치 시험
|
||||
- [x] 83-2 CAD [면적산출로 보내기] 명령 · 부모 메시지(새 `openwebcad/src/integration/aislo-table-export.ts`) · 확인 = bridge 시험
|
||||
- [x] 83-3 면적산출 새 시트로 넣음 · 화면 고침 표시 → [저장] · 되돌리기 한 번(공개 손잡이 addSheet 를 데스크탑 보조가 만듦) · 확인 = 화면 새 시트 칸 · 병합 수치
|
||||
- 검증완료(데스크탑 보조 보고 · 브레인 기록) — 견본 시험 · bridge 5/5 · test_m02_area_from_drawing 5/5 · 화면: 선 표 3×3 · 병합 1 · 열 폭 156/118/118 px · 되돌리기 → 시트 1장 · 탭 숨음 · 임시 항목 [저장] → 병합 B1:C1 · 다시 열면 탭 2장 · 빈 범위 · 글 없는 선 = 안 보냄 · 회전 표 = 글 폴백 · 밝음 · 어두움 · 커밋 d8c75722 · 4e9df0e4 · c7341ef4 · 152d97af
|
||||
|
||||
## 84. 도면 불러오기 오류 · 호 방향 (sub8)
|
||||
|
||||
사용자 확정(10-8) — 엄상준 개인 구조물 조합 중력식사방댐 도면 불러오기 오류 · 작은 호 반전 추정.
|
||||
정함(브레인) — 저장본은 호 0개(선으로 펴짐) → 원본으로 재현 먼저 · 불러오기 bulge · OCS · 겹 블록 고침 · 이동 때 색 · 층 · 호 대칭 · 내보내기 호 같이(분석_정함 1장).
|
||||
|
||||
- [x] 84-1 재현 — 저장본 사본(자기 시스템 층 「sub8임시」) 열기 오류 글 · 시간 + ⬜ 원본 DXF/DWG(사용자) 다시 불러오기 · 확인 = 재현 기록
|
||||
- 검증완료 — 저장본 사본(시스템 층 「sub8임시 중력식사방댐」 · 15.4 MB · 9,319 도형) M02 미리보기 열기 2.56 s · 편집창 열기 · 오류 글 없음 → 저장본으론 재현 안 됨 · 원본 DXF 불러오기 단계 의심(⬜ 원본 받으면 다시) · 항목 지움 · 시스템 층 HEAD 와 같음
|
||||
- [x] 84-2 불러오기 — LWPOLYLINE/POLYLINE bulge · OCS(압출 −Z) · TEXT 자리 · 겹 INSERT(`B07_DesignDetail_Engine_Frame_Import.py`) · 확인 = 견본 DXF 시험 넷
|
||||
- 검증완료 — 견본 DXF 시험(호 · bulge · 거울 블록 · 겹 블록 · TEXT 정렬점 · 내보내기 시계 호) 6 + dxf_clip · frame_export_table = 16 통과 · 옛 코드는 5 실패
|
||||
- [x] 84-3 MOVE/SCALE 색 · 층 유지 · 호 mirror 축 · DXF 내보내기 시계방향 호(`move-tool.ts` · `ArcEntity.ts` · `Frame_Export.py`) · 확인 = CAD 시험
|
||||
- 검증완료 — ArcEntity.test.ts 10(축 4 갈래 대칭 · 끝각 0 · 복제) · clone-keeps-look 7 · typecheck · CAD 빌드 · 화면 MOVE 9,107 도형(빨강 · 층 sub8시험 297 포함) 뒤 id · 색 · 층 · 폴리선 자식 그대로 · 새 id 0
|
||||
- [x] 84-4 큰 도면(15 MB) 열기 감시 시계(`cad_host.ts`) · 확인 = 사본 열기 시간 수치 · 오류 없음
|
||||
- 검증완료 — 15 MB 사본 열기 2.56 s(감시 시계 15 s 안) · 오류 없음 → 시계 고침 불필요
|
||||
|
||||
## 85. M02 일위대가 — 이름 규격 · 켜쌓기 · 순서 · 원문 다운로드 · 패널 스크롤 (sub3)
|
||||
|
||||
사용자 확정(10-8) — 이름 + 규격 · 「골쌓기 기준」 주문 로직에 켜쌓기 적용율(%) 기본 100 · 세부항목 수량 × % · 화살표 작게 + 끌기 · 원문 제목 행 오른쪽 다운로드 · 오른쪽 패널 따로.
|
||||
정함(브레인) — 대상 = 표 주문 자동 판정(지금 GC000260 · 261) · 세부항목 전부 · 0 초과 200 이하 · 사본 · 서고 같은 함수 · pdf 통째 · 로그인 사용자 모두 · 원문 패널 M02 길로(분석_정함 1장).
|
||||
|
||||
- [x] 85-1 드롭다운 「이름 규격」(번호 풍선 · 저장 값 그대로 · 직종도)(`M02_Labor_Inputs.py` · `M02_Labor_Values*.ts` · `Labor_Diagram.ts`) · 확인 = 기계 · 집게 · 직종 고르기 글
|
||||
- 검증완료 — 화면 [옵션] 기계 6 · 집게 3 선택지 글 = 「굴착기(무한궤도) 0.6㎥」 · 「부착용 집게 0.2㎥」 · 풍선 = 원문번호 · 저장 값 번호 · 직종 「1048」 → 건설기계운전사(시험) · test_m02_labor_values 통과
|
||||
- [x] 85-2 켜쌓기 적용율 — `담은로직[].켜쌓기율` · `run_line` 곱 · 옵션 모달 한 줄 · 줄 아래 글(`M02_Labor_Doc.py` · `M02_Item_Prices.py` · `M02_Labor_Ref.py` · `Labor_Input.ts`) · 확인 = GC000260 90% 금액 = 100% × 0.9 · 사본 같음
|
||||
- 검증완료 — 화면 GC000260 90% 기준 계 71,515 → 64,364(× 0.9) · 비목 셋 같음 · 250 = 막힘 · 줄 아래 「켜쌓기 90%」 · 저장본 켜쌓기율 90 · 서버 계 64,363.76 · 프로젝트 사본 · 서고 승격 같은 값(test_m02_labor_layer 11)
|
||||
- [x] 85-3 순서 ▲▼ 작게 + 손잡이 끌기 — 상세 · 카드 · 서고(`Labor_Rows.ts` · `Labor_Detail.ts` · `Labor.css`) · 확인 = 끌기 뒤 저장 순서
|
||||
- 검증완료 — 상세 · 카드 ▲▼(첫 줄 ▲ 꺼짐 · 풍선 위로/아래로) · 손잡이 끌기 2째 → 4째 · ▲ 한 번 → 저장본 순서 같음 · 줄 draggable 아님(별칭 칸 그대로) · test_m02_card_shell 통과
|
||||
- [x] 85-4 원문 다운로드 단추 · `GET /api/m02/logic-lib/source-pdf`(책 이름만) · 원문 읽기 대리 길(`LogicLab_Source.ts` · `Router_Combos.py`) · 확인 = 일반 사용자 내려받기 바이트 수
|
||||
- 검증완료 — 일반 사용자 내려받기 건설 6,996,919 B · 산림 6,129,293 B(%PDF) · 옛 M01 길 403 · 비로그인 401 · 책 밖 400 · 화면 제목 행 오른쪽 맞춤 단추(간격 0) · test_m01_logic_source 통과
|
||||
- [x] 85-5 일위대가 · 면적산출 상세 오른쪽 패널 따로 스크롤(`ui_template_side_panels.ts follow:false` · `AreaPanel.ts` · `Labor_Detail.ts` · `UI_Style.css`) · 확인 = 메인 스크롤 때 패널 이동 0 px
|
||||
- 검증완료 — 메인 스크롤 때 패널 이동 0 px(일위대가 본문 987 px · 면적 5,352 px 굴림) · 패널 높이 = 칸 936 px(바닥 넘침 0) · 원문 줄 고름 때 창 · 본문 이동 0 · 패널 몸 끝까지 굴려도 본문 0 · test_ui_side_panels 통과
|
||||
|
||||
## 86. M02 구조물 조합 — 저장 후 넘어감 · 이름(1) · 일위대가 합계 행 (sub2)
|
||||
|
||||
사용자 확정(10-8) — 버리고 넘어갈까 물음에 [저장 후 넘어감] · 같은 폴더 같은 이름 = 「이름(1)」로 저장 · 상단 표 일위대가 합계 행.
|
||||
정함(브레인) — 단추 셋(기본 초점 취소 · M02 만) · 항목 · 폴더 · 붙여넣기 · 복사 생성도 「이름(1)」 꼴 통일 · 합계 = 반영합(없으면 기준합 「(기준)」)(분석_정함 1장).
|
||||
|
||||
- [x] 86-1 3단추 공용 대화창 + M02 자리 전부 · settle 카드 저장 실패 흠(새 `ui_template/ui_template_leave_dialog.ts` · `UI_Main.ts` · `Structure.ts` · `ItemCards.ts`) · 확인 = 도면 · 일위대가 · 면적산출 미리보기 · 상세마다 저장 후 넘어감 · 실패면 머묾
|
||||
- 검증완료 — test_m02_leave_dialog(Node 거울) · card_sheet_edit · drawing_card · typecheck / ORCA 5177: 면적 카드 미리보기 · 면적 상세 · 일위대가 상세 · 도면 상세 [저장 후 넘어감] → 서버 값 저장(A1 7 · 55 · 단위 m) · 저장 실패 꾸밈(500) → 머묾 · 고침 표시 유지 · [버리고 넘어감] → 서버 값 그대로(null) · Esc = 취소 · 초점 = 취소 · 옵션 정의 화면 = 2단추
|
||||
- [x] 86-2 옮기기 같은 이름 = 「이름(1)」 · 항목 · 폴더 · 여러 개 · `_unique` 한 벌(`Tree.py` · `Item_Paste.py` · `Item_Personal.py` · `TreePanel.ts`) · 확인 = 옮기기 겹침 시험 + 화면
|
||||
- 검증완료 — test_m02_tree · tree_paste · treepanel · item_keys · item_copy_complete · pair_copy 54 통과(옮기기 겹침 시험 새로 · 옛 「(2)」 꼴 시험 고침) / ORCA: 같은 이름 옮기기 → 「같음(1)」 · 토스트 「같은 이름이 있어 「같음(1)」 로 저장」 · 키 · 번호 그대로
|
||||
- [x] 86-3 상단 표 「일위대가 합계」 읽기 전용 줄 · 카드 [저장] 뒤 다시 읽기(`Store.py structure_numbers` · sheet render) · 확인 = 줄 값 = 일위대가 상세 합계
|
||||
- 검증완료 — test_m02_combo_table(합계 · 기준 · 빈 칸) · structure · structure_combo · structure_panel · sheet 계열 / ORCA: 줄 여섯 칸 = labor 문서 계산값(2,658,983 기준 · — · 247,026 기준 · 560,292 · 332,047 · 283,597) 일치 · 카드 [저장] 뒤 numbers 다시 받음 · 어두운 화면 글 · 줄 머리 안 잘림
|
||||
|
||||
## 87. 노면공 패널 — 시작 · 종료 측점 · 길이 출력 · 확폭 구간 (sub5)
|
||||
|
||||
사용자 확정(10-8) — 세부 옵션은 상세내용 안 · 시작 · 종료 측점 · 전/후 없음 · 길이 = 출력(포장사양 다음) · 확폭 구간 단추(켬 = 확폭 포함 · 끔 = 규격만).
|
||||
정함(브레인) — 시작 · 종료 = 위치 줄 · 상세내용 = 포장사양 → 길이 → 높이 · 포장사양 = 콘크리트 · 아스콘 · 쇄석 · 높이 기본 0.20 m · 확폭 단추 = 구간마다(분석_정함 1 · 2장 약속).
|
||||
|
||||
- [x] 87-1 시작 · 종료 측점 · 길이 출력 · 기준 · 전/후 · 길이 입력 없앰 · 옛 키 저장 때 뺌(`design_structure_road.ts` · `B05_Profile_UI_Structures_*.ts` · `구조물정의.json` 노면공 줄) · 확인 = B05 · B06 추가 · 수정 · 저장 수치
|
||||
- 검증완료 — test_52_10(명세 차례 · 포장사양 셋 · dropped · 정의 옵션) · registry_policy · define · m02_structure_definition · typecheck / ORCA 5180 검증_sub5: B05 · B06 노면공 위치 줄 = 시작 · 종료만(기준 칸 숨김) · 상세 내용 차례 포장사양 → 길이 출력 → 높이 · 3+0~8+0 길이 100 · 6+0~9+0 길이 60 · 높이 기본 0.2 · 저장 꼴 chainage = start · options pave_spec · height_m · widening_off(length · before · after 없음 · 판 20) · 옛 length_m 12 · before_m 4 · after_m 8 항목 [수정] 뒤 세 키 빠짐(thickness_cm 20 남음) · 빈 종료 → 안 들어감 + 붉은 칸 둘 · 시작 > 종료 → 안 들어감 · 길이 「—」 · 어두움 · 밝음 · 시험 항목 삭제 · 저장 되돌림(structures []) · ui_checks 52-10 paveOk · 52-15 b05 · handoff · pill 통과
|
||||
- [x] 87-2 M02 집계 길이 = 구간형은 시작~종료 먼저(`M02_Table_Fill.py _length`) · 확인 = 시험
|
||||
- 검증완료 — test_m02_table_fill 새 시험(시작 200~245 · 옛 length_m 30 → 45 · 점형 12 → 12 · 빈 값 None) 통과 · 옛 코드에서는 실패 확인 · 같은 파일 옛 흠 셋(rv 기본 높이 열 id 시험)은 내 변경 전에도 실패
|
||||
- 검증완료(메인 합친 뒤 시험 넷) — 원인 = 옵션 그룹 · 컨테이너 복사 건이 자동 맞춤에서 본체와 두 번 셈(46-3 · 54-6 · 87-2 아님) · 견본에서 본체만 · rv 열 id 1.5(54-36) · fb 열 2련(65-1) · 미지정 4건(배관 컨테이너 셋) · test_m02_table_fill · template_layers · 52_10 · 87_widening_runs · m02_structure_definition 39 통과
|
||||
- [x] 87-3 「확폭 구간」 상자 — 구간마다 단추 · 끈 구간 `options.widening_off`(`B05_Profile_UI_Structures_Form.ts` · `design_structure_pipe_calc.ts` 읽기 손잡이) · 확인 = 곡선 둘 걸친 범위 단추 둘
|
||||
- 검증완료 — test_87_widening_runs(구간 묶기 · 글 왕복 · 안 맞는 값 무시) · typecheck / ORCA 검증_sub5: 0+0~13+0 구간 넷 · 3+0~8+0 곡선 둘 = 단추 둘(4+0 ~ 4+5 · 확폭 2 m / 7+0 · 확폭 2 m) · 6+0~9+0 = 7+0 · 9+0(0.16 m) 단추 둘 · 첫 단추 끔 → widening_off 「80-85.05」 · 둘째 끔 → 「80-85.05,140-140」 · 모두 켬 → 키 빠짐 · 저장 파일 확인 · 다시 열면 끔 상태 그대로 · 범위 안 확폭 없음 → 「범위 안 확폭 없음」 · 범위 비면 「시작 · 종료 측점을 넣으면 보임」 · B06 도 같은 단추(아스콘 · 180-180 저장)
|
||||
- [x] 87-4 포장사양 셋 · 물넘이 겹침 안내 문구 · 시험 고침(test_52_10 · ui_checks 52-10 · 53-1) · 확인 = 시험
|
||||
- 검증완료 — 물넘이 겹침 안내 = 「시작·종료 측점을 고쳐 주세요」 · 시험 고침 test_52_10 · ui_checks 52-10 · 52-15 · 53-1(노면공 = 시작 · 종료만 · 카드 23+0 · 목록 · 카드 ok · 알약 1) 통과 · 53-1 남은 bad = 세월교 벽 · outlet.form · ford.count(노면공 밖 · 이번 변경 전부터)
|
||||
|
||||
## 88. 노면공 횡단 형상 (sub1)
|
||||
|
||||
사용자 확정(10-8) — 높이만큼 노면 바닥 기준 형상 · 종류별 채움(흙막이 참고 · 신규 가능) · 범위 여러 측점 · 확폭 켬/끔. B06 끝 줄 「노면공 구조물군의 형상」 = 이 장.
|
||||
정함(브레인) — 노면선 아래로 두께 · 무늬 셋(포장사양별) · 카드별 얹기 = ① 짝 · 저장 전 범위도 바로 · 토공 면적 영향 없음(분석_정함 1 · 2장 약속).
|
||||
|
||||
- [x] 88-1 카드별 포장 얹기 짝 `section.pavement`(새 `common_util/common_util_structure_pavement.py` ↔ `.ts` · `B06_Section_Router.py` 한 줄 · 초안 `withDraftWalls` 옆) · 확인 = 거울 시험
|
||||
검증완료 — 새 거울 시험(측점 9 · 경계 ±0.02 · 확폭 끔 · 겹침 둘) 같음 · walls 거울 · pavement_and_ford · recompute 11 통과 · typecheck 0
|
||||
- [x] 88-2 그리기 — 두께 · 무늬 셋 · 확폭 끔 = 규격 폭(`B06_Section_UI_Cross_Design_Surface.ts` · 해치 재사용) · B07 따라옴 · 확인 = 카드 두께 · 폭 수치
|
||||
검증완료 — 검증_sub1 화면 12장(콘크리트 5 · 아스콘 4 · 쇄석 3 · 440.241 밖) · 두께 0.300/0.100/0.150 m = height_m · 확폭 켬 폭 = 차도폭(5.000 · 4.317 등) · 확폭 끔 280 = 3.000(규격 · 확폭 1.738 뺌) · 무늬 셋 밝음/어두움 · B07 수확 다각형 +1(무늬는 CAD 도면에 안 감 — 패턴 건너뜀)
|
||||
- [x] 88-3 저장 전 범위 바꾸면 카드 바로 · 확인 = 범위 바꾼 뒤 형상 카드 수
|
||||
검증완료 — 패널 길이 60→20 카드 12→10 · 100 → 13(340 두 겹 0.3+0.1) · 저장 안 하고 B05 다녀옴 · 새로고침 13 유지 · 되돌림 후 0
|
||||
- [x] 88-4 B07 CAD 수확이 SVG pattern 채움을 건너뜀 → 무늬(노면공 셋 · 물넘이 빗금)를 선 · 점으로 펴서 도면에 옮김(수확 코드 · 무늬 다각형으로 자름) · 확인 = B07 도면 무늬 엔티티 수 · DXF 내보내기
|
||||
검증완료 — test_cad_pattern_harvest 4건(빗금 10줄 · 점 12 · 돌 · 밖으로 안 나감) · extent 3 통과 · typecheck 0 · 검증_sub1 B07 도면 2·3장 콘크리트 빗금 · 4장 아스콘 점 · 쇄석 돌(원 78) · 9장 물넘이 빗금 · 포장 없는 1장 무늬 0 · DXF 4장 CIRCLE 78 · 선 증가 · 건너뜀 3(종전과 같음) · 밝음/어두움 · 무늬 칸 px→종이 mm(25.4/96)
|
||||
|
||||
## 89. B06 세월교 · 박스암거 — 관 끝 · 배관 선택 · 수평공간 (sub7)
|
||||
|
||||
사용자 확정(10-8) — 흙막이에 관이 튀어나감(흙막이 사면 이용) · 배관 선택 = 좌우 유입 · 유출 구조 뺀 안쪽 · 유입 수평공간 = 바닥에서 수평 연장 뒤 절토 · 유출 수평공간 필요 없음.
|
||||
정함(브레인) — 관 끝 횡단 + 3D · 유입 수평공간은 흙막이일 때만 · 유출 끝 묻히면 바로 절토(분석_정함 1장).
|
||||
|
||||
- [x] 89-1 관 끝 = 흙막이 전면 사면 따라(`Body_Guard.ts` 240-253 · `Corridor_Structures.ts` 3D) · 확인 = 1:0.3 · Ø1.0 관 윗모서리 벽 밖 0 m
|
||||
- 검증완료 — test_80 관 윗모서리 ↔ 그 높이 전면 0 m(기울기 있는 벽) · test_73_4 · test_79 · 3D 순서 시험 통과 · 화면(검증_sub7 8+15 Ø1.0 양쪽 흙막이) 관 윗모서리 x = 전면 선 x(175.47 · 427.22 px 일치 · 벽 밖 0 m) · 관 L11m
|
||||
- [x] 89-2 세월교 배관 선택 = 측벽 · 흙막이 판정면 뺀 안쪽 전부(`Ford.ts`) · 확인 = 관 위 성토 · 벽-관 사이 클릭 = 배관
|
||||
- 검증완료 — 화면 8+15 관 위 · 벽-관 사이 클릭 = 「세월교 본체 선택」 → 세월교 상자 강조 · 흙막이 위 클릭 = 흙막이 · typecheck
|
||||
- [x] 89-3 유입 수평공간 시작 = 본체 바닥(세월교 바닥판 상면 · 박스 저판 상면)(`Culvert_Geom.ts` 597-602 · `Culvert_Extra.ts` 413-418) · 확인 = 수평선 높이 = 본체 바닥
|
||||
- 검증완료 — test_79 유입 수평선 시작 = 본체 바닥(박스 831.855 · 세월교 848.844 일치) · test_73_4 · test_80 · test_65 통과 · 화면 세월교 수평선 y = 바닥판 상면 y(252.35 px) · 박스(16+12 임시) 수평선 y = 저판 상면 y(338.49 px) · 수평 1.00 m · 다단 사이 시작(`Culvert_Extra.ts` 413-418)은 관 규칙 그대로
|
||||
- [x] 89-4 유출 수평공간 없앰 — 칸 · 기하 · 세트 짝 · 구조물정의 이름 줄(`design_structure_guard.ts` · `Culvert_Tiers.ts` · `Engine_Culvert.py` ↔ `common_util_culvert_sets.ts`) · 확인 = 유출 칸 없음 · 시험
|
||||
- 검증완료 — test_65 유출 칸 berm 없음 · test_79 유출 절토 수평 0 m · 유입 1 m(2 m 적으면 2) · 세트 거울 · 합치기 · M02 정의 시험 통과 · 화면 세월교 · 박스 유출 칸 = 수평공간 없음 · 유입만 있음 · [저장] 뒤 파일에 `outlet_revet_berm_m` 없음 · `outlet_revet_berm_m` 은 합치기 목록에 남겨 다음 [저장] 때 지움(목록에서 빼면 옛 값이 남음)
|
||||
|
||||
## 90. B06 유출 단 수 · 도수로 · 돌붙임 (sub7)
|
||||
|
||||
사용자 확정(10-8) — 세월교 · 박스 유출 = 관보호공 유출 옵션과 같음(단 수 · 「흙막이 추가」 사라짐) · 도수로/돌붙임 = 유출 성토사면 따라 · 두께 수기 · 기본 30 cm · 선 오프셋 · 안에 돌.
|
||||
정함(브레인) — 띠 = 사면 안쪽 · 유출 끝 ~ 원지반 · 배관 · 박스 · 세월교 · 물넘이포장 · 날개벽·없음 유출의 옛 상자는 숨김 · 집계 뺌(분석_정함 1장).
|
||||
|
||||
- [x] 90-1 유출 「추가 흙막이(단)」 공용 · 「+ 흙막이」 없앰 · 집계 단 수로 상자(`Revet_Choice.ts` · `Drainage_Facility.ts` · `Page_Pipe_Options.ts` · `Adjust_Dock.ts` · `M02_Table_Fill_Spec.py`) · 확인 = 단 2 → 횡단 2 · 집계 상자 2
|
||||
- 검증완료 — test_54_27 세월교 · 박스 단 2 → 상자 2 · 0 → 0 · 「+ 흙막이」 없음 · 날개벽이면 칸 · 상자 숨김(값 3 남음) · test_65 집계 단 2 → 2건 · 1 → 1건 · 날개벽 → 0건 · test_54_10 통과 · 화면 8+15 단 2 → 흙막이 상자 2 · 횡단 추가 벽 2 · 「+ 흙막이」 없음(박스도 같음) · [저장] 뒤 집계 흙막이단 2 → 흙막이 상자 2건 · test_m02_table_fill 셋 실패는 고치기 전과 같음(무관)
|
||||
- [x] 90-2 도수로 · 돌붙임 두께 칸 · 띠 + 돌(`design_structure_guard.ts CHUTE_ADD` · `ford_pavement` · 세트 짝 · 오버레이 셋 · `Wall_Hatch.ts` 돌 함수) · 확인 = 띠 두께 0.30 m · B07 따라옴
|
||||
- 검증완료 — test_90_chute_band 띠 두께 = 사면 직각 0.30 m · 사면 아래 · 돌 · 윗면 = 사면선 · 세트 거울(세월교 빈 두께 0.3 · 박스 45 cm → 0.45) · test_52 명세 · test_65 · test_b06_culvert_set 통과 · 구조물정의 「chute_add.thickness」 이름 줄 셋 더함 · 옛 apron_* · PITCHING_THICKNESS_M 지움 · 화면 8+15 돌붙임 띠 3조각 모든 꼭짓점 두께 0.300 m · 주인 카드만 · 박스 도수로 띠 2 · B07 덧그림 주인 측점 +69 엔티티 · 연동 측점 0(B07 연동 표시 넘김 고침) · 밝음 · 어두움 · 바꾼 값 · DB 행 · 관 지점 파일 되돌림
|
||||
|
||||
## 91. B06 원지반 · 암 제약 삭제 · 유출측 복합경사 (sub4)
|
||||
|
||||
사용자 확정(10-8) — 세월교 하단부 원지반 연동 제약 삭제 · 흙막이 · 옹벽 공통 로직의 원지반 · 암 경계 영향 확인 뒤 삭제 · 배수군 유출측 절토사면 복합경사.
|
||||
정함(브레인) — 세월교 바닥 = 하상 처음값 + 손 조정 칸 · 끝 연결선은 남김 · 그 밖 제약은 91-1 목록 뒤 브레인 판정 · 복합경사 공용 도우미(분석_정함 1장).
|
||||
|
||||
- [x] 91-1 제약 목록 — 조사_G 1 표 「해당」 + 화면 재현(측점 · 구조물 · 바뀐 치수) → 브레인 판정 · 확인 = 목록 보고
|
||||
- 검증완료 — 화면 TS(Node) 재현 목록 보고 · 브레인 판정 ① 끊음 ② 끊음(사면 1.0 m) ③ 남김 ④ 끊음(0.01 m) ⑤ 끊음(칸 · 비면 성토) ⑥ 남김
|
||||
- [x] 91-2 세월교 바닥 손 조정 칸 · 원지반 바뀌어도 안 따라감 · 흙막이 기본 높이 같이(`Ford_Geom.ts` · `Body_Guard.ts` 83-95 · `Adjust_Dock.ts`) · 확인 = 원지반 바꾼 사본 바닥 높이 그대로
|
||||
- 검증완료 — ORCA 검증_sub4 8+13 유출 측벽 고름 → 「유출 구조 (세월교)」 칸 「측벽 높이 (자동) 1.5m」 · + = 1.6m · 초안 heightM 1.6(되돌림) · 원지반 −2 m 사본 손 값 바닥 848.344 · 848.064 그대로(자동은 847.454 로 따라감) · test_91 · test_65
|
||||
- [x] 91-3 판정분 지형 의존 끊음(다단 자동 자리 · 설계선 끝 픽셀 판정 등) · 옛 「지형에 막힘」 잔재 지움(`Culvert_Extra.ts` · `Cross_Design.ts`) · 확인 = 줌 · 지형 바꿔도 끝점 · 단 자리 같음
|
||||
- 검증완료 — ORCA 66측점 축척 5 · 20 · 80 px/m 설계선 끝점 같음(160: −5.79 / 3.85 m) · 관 4+5 다단 2단 원지반 0 · ±0.5 m 자리 같음(3.818 · 5.561 m) · 기울기 단면유형 넷 같은 값 · C군 바뀐 측점 0(검증_sub4 에 C군 없음) · test_91
|
||||
- [x] 91-4 유출측 절토 복합경사 공용 도우미 — 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · 집수정 끝 · 절토 쪽 배관 유출 벽 뒤 · `cutLine` 점열 · 3D(`common_util_cross_berm.ts` 외) · 확인 = 암 + 복합경사 측점 무릎 · 소단 수치
|
||||
- 검증완료 — ORCA 카드 절토선 11곳 꺾임 = 암 1:0.4 → 토사 1:1.0 · 8+13 사본 소단 = 사면 3.0 m 뒤 폭 0.5 m · 절토 쪽 배관 유출 벽 뒤 절토선 살아남(1:0.4 → 1:1.0) · 3D 실루엣 소단 꼭짓점 받음(6 → 10점) · test_91 · test_79
|
||||
@@ -46,6 +46,17 @@
|
||||
- **글 넘침(97-2)** — 글을 칸 세로 가운데로 · 칸 폭 = 글 폭에 맞춤(최소 150) · 단축키 후보 목록도 바탕 안(최대 6줄 그대로).
|
||||
- **Space(97-3)** — Space = Enter(명령 · 숫자 · 좌표 · 명령줄) · 빈 입력에서 Enter · Space = 마지막 명령 반복(오토캐드 꼴) · 글 단계(TEXT · MTEXT · 표 칸 · 이름)에선 띄어쓰기 · 한글 조합 중 · Shift/Ctrl+Space · 길게 누름은 제외 · 빈 우클릭은 반복 안 함(지금처럼 확정만).
|
||||
|
||||
## 98. B08 구조물 집계표 「횡단배수 | 배관」 실무 꼴 (덧붙임 · sub7 · Opus high)
|
||||
|
||||
근거 = `사용자요구_1008_2b.md` · `조사_M_B08집계표_배관.md` · 참고 표 원본 = 실무문서 2024 간선임도 2공구 `4.2 수량산출(2공구).xlsx` 「구조물집계표」.
|
||||
|
||||
- 배관 한 곳의 컨테이너 값으로 열을 펼침(설계에 쓰인 값만): 관 공 › Φ{관경}m/m › 관매설(m · B06 관 길이 1 m 올림 그대로) · 커플링(ea · 올림(L ÷ 1본) − 1 · 1본 길이 = M02 구조물집계표 변수 — 지금 8 m · 실무 표는 6 m 로 보임 → 사용자가 M02 변수에서 고침) · 관보호공(집수정 실제 모양별 개소 · 글 = M02 정의 이름 — 「돌집수정ㄷ형」으로 보이려면 M02 이름 고침 · 관경 묶음 안) / 기슭막이 › 형태 › H=높이(m · 유입 흙막이 · 유출 · 실제 선 흙막이 단까지 · 빈 높이 = 그림과 같은 관경 최소 높이) / 돌붙임 › 찰붙임 · 메붙임(㎡).
|
||||
- 돌붙임 찰/메 · 면적은 원천이 없음 → 도수로/돌붙임 컨테이너에 「붙임 형식(찰붙임 · 메붙임)」 · 「면적 ㎡」 칸을 더함(빈칸이면 집계 안 함 · 설계자가 넣음).
|
||||
- 계산 = 서버 단독(새 길 `GET /api/projects/{id}/quantity/summary` · B08 Router 새 파일 · M02 채움 함수 재사용) · 화면은 받은 칸만 그림.
|
||||
- 머리 = 구조물 · 형식 · 규격 3단 + 단위 줄 · 맨 아래 계 줄 · 측점 글 「71+13」(실무 꼴) · 조합 잇기(「횡단배수 | 배관」)는 맨 윗 머리 이름 + 일위대가 · 도면 짝으로 남김(펼친 열에도 조합 id — 95-3 모달).
|
||||
- 집수정 실제 모양(자동이면 지형이 세운 L 등)을 B06 [저장] 때 설계에 남김(관 길이 · 흙막이 단과 같은 길).
|
||||
- 이번은 배관만 · 참고 표의 다른 구조물 열(포장 · 측구 · 개거 등)은 다음.
|
||||
|
||||
## 2. 같은 파일 몫
|
||||
|
||||
| 파일 | 몫 |
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 조사 M — B08 구조물 집계표 「횡단배수 | 배관」 실무 꼴 (10-8 오후 · 읽기 전용 조사 원문)
|
||||
|
||||
결정은 `분석_정함.md` 98 절이 앞섬 · 경로는 루트 기준 · 추정 표시 그대로.
|
||||
|
||||
## 1. 참고 표 재구성
|
||||
- 원본 = `resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(2공구)/수량산출/4.2 수량산출(2공구).xlsx` 「구조물집계표」 시트(1~4행 · 병합 직접 읽음) · 요약 `…(2공구)/_분석보고서.md:38-41`.
|
||||
|
||||
| 열 | ① 구조물 | ② 형식 | ③ 규격 | ④ 단위 |
|
||||
|---|---|---|---|---|
|
||||
| C · D | 기슭막이 | 찰쌓기 | H=1.5 · H=2.0 | m |
|
||||
| E · F | 기슭막이 | 메쌓기 | H=1.5 · H=2.0 | m |
|
||||
| G · H | 돌붙임 | 찰붙임 · 메붙임 | L3:35 | m2 |
|
||||
| I · J | 반중력식옹벽 | — | H=2.0 · H=1.6 | m |
|
||||
| K · L | 떼공 | 평떼 | 구조물 · 노견 | m2 |
|
||||
| M · N | L형측구 | — | A형H=0.2 · C형H=0.7 | m |
|
||||
| O · P | 포장난간 | — | A형H=0.2 · C형H=0.7(측구에서 복사 흔적 · 추정) | m |
|
||||
| Q~U | 콘크리트포장 | B · L · A · 확폭 · 수축줄눈 | — | m · m · m2 · m2 · m |
|
||||
| V~Y | 혼합석부설 | B · L · A · 확폭 | — | m · m · m2 · m2 |
|
||||
| Z | 물넘이 포장 | — | H=1.0 | m |
|
||||
| AA · AB | 방지턱 개거 · 수로관 개거 | — | 500*1000 · 400C | m |
|
||||
| AC | 토사개거 | | | 개소 |
|
||||
| AD | 레일형 개거 | — | 3W 460*4330 | 개소 |
|
||||
| AE | 안전난간 | — | 250*200*400 | 개 |
|
||||
| AF · AG · AH | 국가지점번호판 · 임도준공표지석 · 취수정 | | | 개소 |
|
||||
| AI · AJ | 콘크리트깨기 | — | 무근30cm미만 | m3 · ton(추정) |
|
||||
| AK~AN | 관 공 | Φ800m/m | 관매설 · 커플링 · 관보호공 | m · ea · 돌집수정ㄷ형 · 돌집수정ㄴ형 |
|
||||
| AO · AP | 〃 | Φ1000m/m | 관매설 · 커플링 | m · ea |
|
||||
| AQ · AR | 〃 | Φ1200m/m | 관매설 · 커플링 | m · ea |
|
||||
- 관보호공 단위: 4행에 형식 · 실제 단위 개소(1공구 「관보호공 돌집수정 ㄷ형 3 개소」 `…(1공구)/_분석_수량산출.md:53-56`).
|
||||
- 원본 값: 전구간 S=Q×R · U=INT(R/6.00000001)×Q(식) · AC 10 / 71+13 D10 F20 G10 H10 AQ22 AR3 / 75+0 AF1 / 78+7 F10 H20 AK18 AL2 AM1 / 84+0 F10 H20 AK16 AL2 AN1 / 89+10 E10 H10 AK18 AL2 AN1 / 100+0 AF1 / 102+0 F10 H10 AK16 AL1 AM1 / 105+12 D10 F10 G10 H10 AO16 AP2 / 112+0 E10 H10 AK16 AL2 AN1 / 계(SUM) AK84 AL9 AM2 AN3 AO16 AP2 AQ22 AR3 · 붙여넣기는 71+13 첫 값 10(D)이 빠지고 75+0 에 10 이 붙음(원본 75+0 은 번호판 1뿐).
|
||||
- 관 한 곳 = 세트(1공구 `_분석_대조.md:22 · 31` 같음): 유입 집수정(Φ800 다섯 곳) = 관보호공 1 + 기슭막이 메쌓기 10 m(유출) + 메붙임 10~20 ㎡ / 유입 흙막이(Φ1200 71+13 · Φ1000 105+12) = 기슭막이 찰 H2.0 10 m + 메 H2.0 10~20 m + 찰붙임 10 + 메붙임 10 · 「유입 찰 · 유출 메」(추정 · 옛 보호공 기본값 `common_util/common_util_drainage_pipes.py:301-302` 와 같음).
|
||||
- 커플링(추정): INT(L/6.00000001) = 「올림(L/6) − 1」 · 수축줄눈 식과 같은 6 m · 16 · 18 m → 2 · 22 m → 3 맞음 · 102+0(16 m → 1)만 어긋남(손 값 추정).
|
||||
|
||||
## 2. 지금 B08 집계표
|
||||
- 화면 `B08_Quantity/B08_Quantity_UI_Summary.ts:71-106` — 구조물 · M02 조합 줄(GET /api/m02/items?all=1) · 관 지점(`fetchDetailPipePoints` :78 · options 까지) · 측점 간격.
|
||||
- 줄 · 열(`B08_Quantity_Summary_Data.ts`): 줄 = 측점 1 cm 묶음(:51-58) · 열 = `summary_item` + `group_items` 모든 값(:61-83) · 구분 → 이름 가나다.
|
||||
- 머리 2단(M02 경로[0] / 나머지 · `UI_Summary.ts:84-90`) · 단위 줄 없음(조합 `단위` 받으나 안 씀 `ui_template_item_picker_data.ts:29`) · 칸 값 채우는 곳 없음(rows 에 측점만 :60-61) · 합계 줄 없음.
|
||||
- 「횡단배수 | 배관」 열 = 관 본체 · 컨테이너에 이은 M02 조합 경로가 「횡단배수 › 배관」(추정) · 윗 머리 「횡단배수」 · 아래 「배관」 · 단위 · 값 없음 · 컨테이너마다 다른 조합을 이으면 열 따로(관 `grv6lny` · 유입 `gnbf9m9` · 유출 `g9lq9uk` · 흙막이 n `gsoiladd{n}` · 도수로/돌붙임 `gchuteadd{n}` — `A00_Common/design/design_structure_pipe.ts:36-92` · 잇는 자리 `B05_Profile/B05_Profile_UI_Structures_Form.ts:152-158`).
|
||||
- 열 펼치는 엔진은 M02 에: `M02_MasterTemplete/M02_Table_Fill.py`(펼침 · 묶음 · 머리틀 · 값꼴 · 값 = 개소|길이|관연장|옵션 키 :204-214 · 376-420 · 459-561) · 컨테이너 줄(`M02_Table_Fill_Spec.py:73-105`) · 흙막이는 선 단 수까지(:46-55 · 87-94) · 길 GET /api/m02/projects/{id}/tables/{name}/filled(`M02_MasterTemplete_Router_Layers.py:579-605`) · M02 화면만 부름(`UI_Main.ts:490`) · B08 은 안 씀 · M02 표 열 꼴(`머리`[] + `단위`) = 참고 표 4행 모양.
|
||||
- 그 엔진이 값을 못 내는 까닭: 「지정 항목 모드」(:627-685) · 조합 복사본 열에 `바인딩` 필요 · 마스터 조합 `바인딩: null`(`resources/master_template/구조물집계표조합.json`) · M02 화면에 바인딩 편집 칸 없음(`M02_MasterTemplete_Api_Fetch.ts:208` 꼴만) · 옛 규칙 열(pp_len · pp_cp · pg_in · rv)은 시험 견본에만(`resources/tester/fixtures/m02_master_sample/table/구조물집계표.json`).
|
||||
|
||||
## 3. 배관 컨테이너 값 → 칸
|
||||
| 참고 표 칸 | 컨테이너 · 값(키) | 원천 · 계산 자리 | 지금 B08 |
|
||||
|---|---|---|---|
|
||||
| 관 공 › Φ{D}m/m | 관 · `pipe_diameter_mm`(800 · 1000 · 1200 · 1500 · 기본 1000) | 폼 `design_structure_pipe.ts:42-49` → `pipe_points.json` options · 저장 `B05_Profile_Api_Pipes_Draft.ts:31-36` · B06 구간값 `B06_Section_Api_Culvert_Options.ts:64-82` | 옴 · 안 씀 |
|
||||
| 관매설 m | 관 길이(자동 · 폼 보기만 `B05_Profile_UI_Drainage_Facility.ts:339-343`) | B06 기하 1 m 올림(`B06_Section_UI_Cross_Culvert_Geom.ts:287-288 · 505 · 569`) → 설계 `pipe_length_m` · 주인 측점만(`B06_Section_Structure_Layouts.ts:198-209` · Node `B06_Section_Server_Calc_Prebuild.py:63-69 · 314-325`) | 안 옴 |
|
||||
| 커플링 ea | 없음 | 계산 없음 · M02 표 변수 `파형강관_1본_m: 8`(`resources/master_template/table/구조물집계표.json`)만 · 옛 식 「올림(L÷1본)−1」은 지워져 견본 설명에만(`test_m02_table_fill.py:133-140`) | 없음 |
|
||||
| 관보호공 › 돌집수정ㄷ/ㄴ형 개소 | 유입 `inlet_type`=집수정(기본) + 모양 `inlet_structure` I · L · U(이름 I형 · L형 · ㄷ형) | 모양 차례: 횡단 patch `inlet_structure`(auto 아니면) > 옵션 · 안 고르면 집수정 없음 · 「자동」이면 지형 규칙이 L(`B06_Section_UI_Cross_Culvert_Basin.ts:40 · 83-102`) · 실제 선 모양은 저장 안 됨 · 재료 칸 은퇴(`design_structure_pipe.ts:95`) | 옵션만 · patch · 자동 결과 안 옴 |
|
||||
| 기슭막이 › 찰/메쌓기 › H= m | 유입(흙막이) · 유출 · 흙막이 n — 형태 · 높이 · 길이(=전+후) `{inlet\|outlet}_revet_*` · `soil_add_{n}_*` | 폼 `design_structure_guard.ts:23-29 · 33-83 · 104-111` · B06 조작이 같은 키(`B06_Section_UI_Page_Structures_Spec.ts:155-173` · `B06_Section_UI_Page_Pipe_Options.ts:103-144 · 196-247`) · 높이 빈 칸 = 관경 최소(Φ800 1.8 · Φ1000 2.0 `B06_Section_Engine_Culvert.py:84-91`) · 흙막이 개수 = 실제 선 유출 단(설계 `extra_walls` `Structure_Layouts.ts:247-321`) | 옵션만 · `extra_walls` 안 옴 |
|
||||
| 돌붙임 › 찰/메붙임 › L3:35 m2 | 도수로/돌붙임 · 구조 · 두께 cm(기본 30 · 하나만) | `design_structure_guard.ts:114-136` · B06 그림만(`B06_Section_Engine_Culvert.py:229-243` · `B06_Section_UI_Cross_Wall_Hatch.ts:326-362`) · 면적 · 찰/메 원천 없음(옛 `*_protection_area_m2` 옮기기만 `common_util_drainage_pipes.py:305-325`) | 옵션만 · 면적 없음 |
|
||||
- 「B06 좌측 패널」 = B05 구조물 배치 폼과 같은 폼(일원화 `B06_Section_UI_Adjust_Dock.ts:1-15`) · 저장 두 곳: 폼 값 → `pipe_points.json` options / 조정값(높이 · 형태 h/m · 단 수 · 단 구간 · 유입 형식) → 횡단 설계 patch(`B06_Section_Schema.py:148-160`) · [저장] 때 서버 Node 가 `pipe_length_m` · `extra_walls` 를 같은 설계에 얹음.
|
||||
- B08 이 이미 받는 것: 토공 길이 `get_cross_section_designs`(확정 노선 먼저 `B08_Quantity_Router_Earthwork.py:39-48`) — 같은 줄에 `pipe_length_m` · `extra_walls` 있으나 면적만 씀 · 새로 읽을 것: 그 둘 · 집수정 실제 모양 · 1본 길이 변수.
|
||||
|
||||
## 4. 고칠 자리 · 크기(계산은 서버 단독 ③ · 지금 집계표는 브라우저 TS 뿐)
|
||||
1. 머리 4단 + 단위(작음) `UI_Summary.ts:47-59 · 84-90` — 경로를 `group` 여러 단 · `label` = 조합 `단위` · 공용 표 여러 단 지원(`ui_template_data_table.ts:73-106` · 빈 단 세로 합치기는 없음 · 선택).
|
||||
2. 배관 펼침 · 값(중간 · 서버 ~150줄 · TS ~40줄) — 새 길 `GET /api/projects/{id}/quantity/summary`(B08 Router 새 파일) · 재사용 `M02_Table_Fill.collect_items`(관 · 컨테이너 줄 · `관연장` · `흙막이단`) · `pipe_lengths_from_designs` · `soil_tiers_from_designs` · 배관 규칙(관경마다 관매설 · 커플링 · 관보호공 / 형태 · H 마다 기슭막이 길이 / 돌붙임) 자리 = `M02_Table_Fill_Spec.py` 컨테이너 표 옆 · 머리 글 = 정의 이름(`구조물정의.json` 종류.pipe.이름 · 사용자가 M02 에서 고침) · TS 는 `UI_Summary.ts` 칸 채움(rows :61) · `total` 로 합계 줄.
|
||||
3. 집수정 실제 모양 정본 저장(작음) — `extraWallRows`(`B06_Section_Structure_Layouts.ts:318`)에 `inlet_basin`(모양|null) · Prebuild 합침(`B06_Section_Server_Calc_Prebuild.py:279-289`)에 한 칸(관 연장과 같은 길).
|
||||
4. 커플링(작음) — 서버 식 한 줄(관마다 올림(L÷1본)−1) · 1본 길이는 프로젝트 층 M02 표 `변수`(읽는 꼴 `Router_Layers.py:588-594`).
|
||||
5. 돌붙임 면적 · 찰/메(작음~중간 · 미결) — 원천 없음 · `CHUTE_ITEMS`(`design_structure_guard.ts:114-125`) + 정의 + `_chute` 에 칸 · 또는 B06 띠 길이 × 폭.
|
||||
6. 경계(추정 권장): M02(사용자) = 조합 경로(머리 글) · 단위 · 정의 이름(ㄷ형 등) · 표 변수(1본 길이) / 코드 = 값 원천 읽기 · 펼침 · 계산 · 바인딩 규칙은 코드(M02 화면에 편집 칸 없음).
|
||||
|
||||
## 5. 시험 · 겹침 · 주의
|
||||
- 시험: `test_61_1_b08_summary_table.py`(Summary_Data 를 common_util_summary_item 과 이어 node — 새 import 금지) · `test_61_2_b08_structure_sheets.py`(Drawing 이 `summaryColumns` 씀 `UI_Drawing.ts:59`) · `test_ui_data_table.py`(머리 html 글) · `test_m02_table_fill.py` · `test_52_1b_pipe_links.py` · `test_structures_summary_snapshot.py`(M02 채움 재사용 시) · `test_b06_pipe_length.py`(`STRUCTURE_ROW_KEYS` ↔ `_AREA_KEYS` 짝 · 새 키 같은 꼴) · `test_b08_extra_walls.py` 이미 깨짐(옛 엔진 `tmp/old_code/B08_Quantity/`).
|
||||
- 겹침: PLAN 95-3(집계표 행 → 구조물도 모달)이 같은 `UI_Summary.ts` · `Summary_Data.ts` — 펼친 열에도 조합 id 남겨야 모달 동작.
|
||||
- 주의(추정): B08 열은 실제 안 선 흙막이 n 잇기까지 셈(`Summary_Data.ts:68`) · M02 채움은 선 단까지 · M02 지정 모드 `_assigned_match`(`M02_Table_Fill.py:238-247`)는 종류 같은 첫 출처만(유출이 rv 꼴 열 고르면 유입 키) · 빈 높이: 그림은 관경 최소 · M02 `_with_defaults`(:75-81)는 정의 기본 1.5 · 노선: M02 채움은 작업 노선 · B08 토공은 확정 노선 먼저.
|
||||
|
||||
## 미결(조사자 제시)
|
||||
1. 조합 잇기(「횡단배수 | 배관」) 용도: 맨 윗 머리 이름 + 일위대가 · 도면 짝으로 남김(권장) vs 지움. 2. 커플링 1본 길이: 실무 6 m(2공구 8줄 중 7줄) vs 변수 8 m. 3. 관보호공 글: U = 돌집수정ㄷ형 · L = 돌집수정ㄴ형(추정) · I = ? · 「돌」 근거 · 자리 = 관경 묶음 안. 4. 기슭막이 형태(콘크리트 · 돌망태 · 통나무 · 바자) 별도 열 · 빈 높이 「미확정」 표시. 5. 돌붙임 찰/메 · 면적 입력 칸(옛 기본 유입 찰 · 유출 메 · 10 ㎡) vs B06 띠로 셈 · L3 = 두께? 6. 관매설 B06 1 m 올림 그대로. 7. 전구간 · 계 줄 · 측점 글 「NO.71+13」 vs 「71+13」.
|
||||
@@ -37,4 +37,47 @@ assert.equal(paintColor('#f5f7fa'), '#000000', '밝은 바탕: 기본색은 검
|
||||
assert.equal(paintColor('#ff0000'), '#ff0000', '밝은 바탕: 빨강 그대로');
|
||||
assert.notEqual(paintColor('#ffff00'), '#ffff00', '밝은 바탕: 노랑 등 밝은 색은 대비 보정(기존)');
|
||||
|
||||
// 커서 입력창 — 칸 · 글 색이 두 테마 모두 달라야(자동색 둘을 쓰면 paintColor 가 같은 색으로 뒤집음)
|
||||
const { inputFieldColors } = await import(
|
||||
'../../../B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts'
|
||||
);
|
||||
const TOKENS: Record<string, Record<string, string>> = {
|
||||
light: {
|
||||
'--cad-chrome-raised': '#ffffff',
|
||||
'--cad-line': '#e6e2e3',
|
||||
'--cad-text': '#333333',
|
||||
'--cad-text-dim': '#615e6e',
|
||||
},
|
||||
dark: {
|
||||
'--cad-chrome-raised': '#251f38',
|
||||
'--cad-line': '#322a45',
|
||||
'--cad-text': '#e4e0f0',
|
||||
'--cad-text-dim': '#b3aec6',
|
||||
},
|
||||
};
|
||||
for (const name of ['light', 'dark']) {
|
||||
theme = name;
|
||||
const colors = inputFieldColors(
|
||||
(token: string, fallback: string) => TOKENS[name][token] ?? fallback
|
||||
);
|
||||
assert.equal(
|
||||
colors.background,
|
||||
TOKENS[name]['--cad-chrome-raised'],
|
||||
`${name}: 칸 = 토큰 색 그대로`
|
||||
);
|
||||
assert.notEqual(colors.background, colors.text, `${name}: 칸 · 글 색 다름`);
|
||||
assert.notEqual(colors.background, colors.dimText, `${name}: 칸 · 안내 글 색 다름`);
|
||||
assert.notEqual(colors.background, colors.border, `${name}: 칸 · 테두리 색 다름`);
|
||||
// 옛 상수 짝(#000000 칸 · #FFF 글)은 paintColor 를 거치면 같은 색 — 입력창은 건너뛰어야
|
||||
assert.equal(
|
||||
paintColor('#000000'),
|
||||
paintColor('#FFF'),
|
||||
`${name}: 자동색 두 개는 같은 색으로 뒤집힘`
|
||||
);
|
||||
}
|
||||
// 토큰 못 읽으면 폴백도 칸 · 글 다름
|
||||
const fallback = inputFieldColors((_token: string, value: string) => value);
|
||||
assert.notEqual(fallback.background, fallback.text, '폴백: 칸 · 글 색 다름');
|
||||
assert.ok(!isAutoColor(fallback.background) && !isAutoColor(fallback.text), '폴백: 자동색 아님');
|
||||
|
||||
console.log('theme 자동색 시험 통과');
|
||||
|
||||
@@ -82,6 +82,12 @@ console.log(
|
||||
frozen: 1,
|
||||
empty: "",
|
||||
}),
|
||||
pick: m.renderDataTableHtml({
|
||||
columns: [{ key: "x", label: "X" }],
|
||||
rows: [{ x: 1 }, { x: 2 }],
|
||||
total: { x: 3 },
|
||||
onRowClick: () => undefined,
|
||||
}),
|
||||
escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "<b>" }], rows: [{ x: "a&<" }] }),
|
||||
noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes("<tfoot>"),
|
||||
}),
|
||||
|
||||
@@ -72,9 +72,29 @@ def test_빈_목록():
|
||||
assert out == [[], [], []]
|
||||
|
||||
|
||||
def test_측점_조합들():
|
||||
# 95-3 — 한 측점(1cm 안) 조합 전부 · 본체 → 그룹 차례 · 구조물 id 따라감 · 같은 구조물 같은 조합은 한 건
|
||||
things = [
|
||||
{"structure_id": "s1", "chainage_m": 40.004, "summary_item": _item("SS1"),
|
||||
"group_items": {"g1": _item("SS3"), "g2": _item("SS1")}},
|
||||
{"structure_id": "pipe@40.0", "chainage_m": 40.0, "summary_item": _item("SS1")},
|
||||
{"structure_id": "s2", "chainage_m": 80.0, "summary_item": _item("SS2")},
|
||||
] # fmt: skip
|
||||
out = _run(
|
||||
f"\nconst things = {json.dumps(things, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify([combosAt(things, 40).map((c) => [c.id, c.structureId]),"
|
||||
" combosAt(things, 80).length, combosAt(things, 99).length]));"
|
||||
)
|
||||
assert out == [
|
||||
[["system||SS1", "s1"], ["system||SS3", "s1"], ["system||SS1", "pipe@40.0"]],
|
||||
1,
|
||||
0,
|
||||
]
|
||||
|
||||
|
||||
def test_집계표는_공용_표():
|
||||
# PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님)
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui
|
||||
assert "frozen: 1" in ui and 'empty: ""' in ui
|
||||
assert "frozen: 1" in ui and 'empty: ""' in ui and "onRowClick" in ui
|
||||
assert 'document.createElement("table")' not in ui
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
"""61-2 B08 2탭 구조물도 — 탭에 붙음 · 읽기만 · 집계표와 같은 조합 목록 · 안내 글 키."""
|
||||
"""61-2 · 95-3 B08 구조물도 모달 — 집계표 행에서 열림 · 읽기만 · 집계표와 같은 조합 목록 · 안내 글 키."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
DRAWING = Path("B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8")
|
||||
SUMMARY = Path("B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
PAGE = Path("B08_Quantity/B08_Quantity_UI_Page.ts").read_text(encoding="utf-8")
|
||||
LOCALE = Path("ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8")
|
||||
LOCALE_B2 = Path("ui_template/ui_template_locale_b2.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_sheet_tab_wired():
|
||||
assert 'tab === "sheet"' in PAGE and "renderStructureSheets(view, projectId)" in PAGE
|
||||
def test_sheet_tab_gone_modal_wired():
|
||||
# 구조물도 탭 없음 · 집계표 행 누름이 모달을 늦게 불러 엶
|
||||
assert '"sheet"' not in PAGE and "renderStructureSheets" not in PAGE
|
||||
assert "B08_Quantity_Tab_StructureSheet" not in PAGE + LOCALE + LOCALE_B2
|
||||
assert "onRowClick" in SUMMARY and 'import("./B08_Quantity_UI_Drawing")' in SUMMARY
|
||||
assert "combosAt(things, station)" in SUMMARY
|
||||
|
||||
|
||||
def test_modal_own_class_and_cleanup():
|
||||
css = Path("B08_Quantity/B08_Quantity_UI_Drawing.css").read_text(encoding="utf-8")
|
||||
assert "min(1320px, 96vw)" in css and "--modal-max: 94vh" in css
|
||||
assert "beforeClose" in DRAWING and "cards.destroy()" in DRAWING
|
||||
assert 'dialogClass: "b08-sheets"' in DRAWING
|
||||
|
||||
|
||||
def test_read_only():
|
||||
@@ -19,10 +32,12 @@ def test_read_only():
|
||||
assert "editable: () => false" in DRAWING
|
||||
|
||||
|
||||
def test_same_columns_as_summary():
|
||||
assert "summaryColumns(" in DRAWING and "./B08_Quantity_Summary_Data" in DRAWING
|
||||
def test_copy_first_then_master():
|
||||
# 같은 항목의 프로젝트 복사본이 있으면 복사본 · 없으면 M02 원본 층
|
||||
assert "sameItem(own, row)" in DRAWING and "structureFolder(" in DRAWING
|
||||
assert "readTemplate(layer, part, column, null)" in DRAWING
|
||||
|
||||
|
||||
def test_locale_keys_exist():
|
||||
for key in set(re.findall(r'L\("([A-Za-z0-9_]+)"\)', DRAWING)):
|
||||
assert f"{key}:" in LOCALE or key.startswith("B08_Quantity_Summary_"), key
|
||||
assert f"{key}:" in LOCALE or key.startswith(("B08_Quantity_Summary_", "Common_")), key
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""B08 수리 표 ← B05 배수유역 연결값 (PLAN 96-1 · 96-3).
|
||||
|
||||
견본 세부유역 + 관 지점 → 측점마다 linked(A · L · H · 관경 · 박스 내공 · 월류 폭) ·
|
||||
그 값으로 계산한 Qd ≈ B05 설계유량 · 표준값은 B05 배수 설정 하나.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Hydro_Defaults import DEFAULTS, station_rows # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Hydro_Site import BASINS_FILE, read_basins # noqa: E402
|
||||
from common_util.common_util_drainage_detail import size_pipe # noqa: E402
|
||||
from common_util.common_util_drainage_pipes import PipePoint # noqa: E402
|
||||
from common_util.common_util_wamis_station import mononobe_idf # noqa: E402
|
||||
from config import config_system_design as cfg # noqa: E402
|
||||
|
||||
R24 = 231.56
|
||||
BASINS = [
|
||||
# B05 저장 꼴 — 측점 0.01 반올림 · 면적 0.1 · 표고차 0.01 · 길이 0.1
|
||||
{"kind": "detail_basin", "chainage_m": 85.05, "area_m2": 10717.0, "flow_length_m": 163.9,
|
||||
"relief_m": 120.0},
|
||||
{"kind": "detail_basin", "chainage_m": 173.09, "area_m2": 141145.0, "flow_length_m": 603.3,
|
||||
"relief_m": 320.0},
|
||||
{"kind": "detail_basin", "chainage_m": 300.0, "area_m2": 52000.0, "flow_length_m": 410.0,
|
||||
"relief_m": 0.0},
|
||||
] # fmt: skip
|
||||
for _b in BASINS:
|
||||
_out = size_pipe(
|
||||
_b["area_m2"], max(_b["relief_m"], 0.1), _b["flow_length_m"], mononobe_idf(R24)
|
||||
)
|
||||
_b.update(design_flow_m3s=_out["design_flow_m3s"], tc_minutes=_out["tc_minutes"])
|
||||
|
||||
POINTS = [
|
||||
PipePoint(chainage_m=300.004, facility="box_culvert",
|
||||
options={"body_width_m": 2.0, "body_height_m": 1.5}),
|
||||
PipePoint(chainage_m=85.052, options={"pipe_diameter_mm": "1000", "pipe_kind": "파형강관"}),
|
||||
PipePoint(chainage_m=173.09, facility="ford_bridge", options={"ford_width_m": 10}),
|
||||
PipePoint(chainage_m=500.0, options={"pipe_diameter_mm": 800}), # 유역 짝 없음
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_측점마다_연결값() -> None:
|
||||
rows = station_rows(POINTS, BASINS)
|
||||
assert [r["chainage_m"] for r in rows] == [85.052, 173.09, 300.004, 500.0]
|
||||
pipe, ford, box, lone = (r["linked"] for r in rows)
|
||||
assert pipe == {
|
||||
"area_km2": 0.010717,
|
||||
"length_m": 163.9,
|
||||
"height_m": 120.0,
|
||||
"diameter_mm": 1000.0,
|
||||
}
|
||||
assert ford == {"area_km2": 0.141145, "length_m": 603.3, "height_m": 320.0,
|
||||
"overflow_width_m": 10.0} # fmt: skip
|
||||
# 표고차 0 은 빈칸(저장 검사 gt 0) · 박스는 내공
|
||||
assert box == {"area_km2": 0.052, "length_m": 410.0, "box_width_m": 2.0, "box_height_m": 1.5}
|
||||
# 유역 짝이 없으면 시설 치수만 · B05 참고값 없음
|
||||
assert lone == {"diameter_mm": 800.0} and rows[3]["b05"] is None
|
||||
assert rows[0]["b05"] == {"design_flow_m3s": BASINS[0]["design_flow_m3s"], "tc_minutes": 5.0}
|
||||
|
||||
|
||||
def test_잇지_않는_칸() -> None:
|
||||
for row in station_rows(POINTS, BASINS):
|
||||
assert not {"slope_denominator", "pipe_kind", "n"} & set(row["linked"])
|
||||
|
||||
|
||||
def test_basins_를_안_주면_linked_없음() -> None:
|
||||
rows = station_rows(POINTS)
|
||||
assert all(r["linked"] is None and r["b05"] is None for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", [0, 1])
|
||||
def test_연결값으로_계산한_Qd_는_B05_설계유량(index: int) -> None:
|
||||
row = station_rows(POINTS, BASINS)[index]
|
||||
out = compute({**DEFAULTS, **row["linked"], "r24_mm": R24, "sheet": row["sheet"]})
|
||||
assert out["design_flow"] == pytest.approx(row["b05"]["design_flow_m3s"], abs=1e-4)
|
||||
assert out["tc_hr"] * 60 == pytest.approx(row["b05"]["tc_minutes"], abs=0.05)
|
||||
|
||||
|
||||
def test_표준값은_B05_배수_설정() -> None:
|
||||
assert DEFAULTS["runoff"] == cfg.DRAINAGE_RUNOFF_COEFFICIENT == 0.8
|
||||
assert DEFAULTS["factor"] == cfg.DRAINAGE_DESIGN_FLOW_FACTOR == 2.0
|
||||
assert DEFAULTS["return_period"] == cfg.DRAINAGE_DESIGN_RETURN_PERIOD_YR == 100
|
||||
assert DEFAULTS["tc_min_minutes"] == cfg.DRAINAGE_TC_MIN_MINUTES == 5.0
|
||||
|
||||
|
||||
def test_세부유역_파일_읽기(tmp_path: Path) -> None:
|
||||
assert read_basins(tmp_path) == [] # 분석 저장 전
|
||||
features = [{"type": "Feature", "properties": b, "geometry": None} for b in BASINS]
|
||||
features.append({"type": "Feature", "properties": {"kind": "pipe", "chainage_m": 85.05}})
|
||||
path = tmp_path / BASINS_FILE
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps({"type": "FeatureCollection", "features": features}), "utf-8")
|
||||
assert [b["chainage_m"] for b in read_basins(tmp_path)] == [85.05, 173.09, 300.0]
|
||||
@@ -101,7 +101,25 @@ def test_소유_측점에만_싣는다():
|
||||
layouts = _LAYOUTS.read_text(encoding="utf-8")
|
||||
assert "pipeOwnerChainage" in layouts, "소유 측점을 안 가리고 있다"
|
||||
assert "section.culvert?.chainage_m" in layouts, "관이 놓인 자리를 안 읽고 있다"
|
||||
py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(encoding="utf-8")
|
||||
py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
ts = (PROJECT_ROOT / "common_util" / "common_util_culvert_sets.ts").read_text(encoding="utf-8")
|
||||
assert '"chainage_m": pipe_chainage' in py
|
||||
assert "chainage_m: pipeChainage" in ts
|
||||
|
||||
|
||||
def test_집수정_실제_모양이_같은_길로_정본에_실린다():
|
||||
"""98-2 — 실제로 선 집수정 모양(patch · 자동 지형 규칙 결과)이 다단 벽과 같은 줄로 정본에 감.
|
||||
|
||||
TS 가 줄에 싣고(`ExtraWallRow.inlet_basin`) 서버가 합칠 때 받아야 함 — 한쪽만 있으면 조용히 버려짐.
|
||||
"""
|
||||
layouts = _LAYOUTS.read_text(encoding="utf-8")
|
||||
block = layouts[layouts.index("export interface ExtraWallRow") :]
|
||||
assert "inlet_basin: BasinShape | null" in block[: block.index("}")]
|
||||
assert "inlet_basin: culvert.basin?.shape ?? null" in layouts
|
||||
prebuild = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
merged = prebuild[prebuild.index("extra_walls = [") :][:900]
|
||||
assert '"inlet_basin": row.get("inlet_basin")' in merged
|
||||
|
||||
@@ -1,48 +1,89 @@
|
||||
"""B07 용지도 — 필지마다 지번·지목·면적·소유자 한 묶음 · 빈 값은 빈 줄 (59-6)."""
|
||||
"""B07 용지도 — 필지마다 지번·지목·면적·소유자 상자 + 지시선 (59-6 · 68-2 · 93-3).
|
||||
|
||||
import pytest
|
||||
① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선
|
||||
② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번
|
||||
③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침
|
||||
"""
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import (
|
||||
JIBUN_LAYER_ID,
|
||||
MM,
|
||||
build_landuse_drawing,
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from shapely.geometry import LineString, Point
|
||||
from shapely.geometry import box as box_polygon
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
build_plan_kind_drawing,
|
||||
layer_id,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
|
||||
PARCEL_FONT,
|
||||
ROUTE_CLEAR_M,
|
||||
clip_parcels,
|
||||
place_labels,
|
||||
)
|
||||
|
||||
|
||||
def _square(x0: float, size: float) -> list[tuple[float, float]]:
|
||||
return [(x0, 0.0), (x0 + size, 0.0), (x0 + size, size), (x0, size), (x0, 0.0)]
|
||||
BOX = (0.0, 0.0, 400.0, 300.0)
|
||||
|
||||
|
||||
def test_one_label_group_per_parcel() -> None:
|
||||
def _square(x0: float, y0: float, size: float) -> list[list[float]]:
|
||||
return [[x0, y0], [x0 + size, y0], [x0 + size, y0 + size], [x0, y0 + size], [x0, y0]]
|
||||
|
||||
|
||||
def _geojson(tmp_path: Path, features: list[tuple[dict, dict]]) -> Path:
|
||||
path = tmp_path / "parcels.geojson"
|
||||
payload = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{"type": "Feature", "properties": props, "geometry": geometry}
|
||||
for props, geometry in features
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _polygon(ring: list[list[float]]) -> dict:
|
||||
return {"type": "Polygon", "coordinates": [ring]}
|
||||
|
||||
|
||||
def _rect(item: dict) -> tuple[float, float, float, float]:
|
||||
width, height = balloon_size(item["lines"], PARCEL_FONT)
|
||||
cx, cy = item["center"]
|
||||
return (cx - width / MM / 2, cy - height / MM / 2, cx + width / MM / 2, cy + height / MM / 2)
|
||||
|
||||
|
||||
def test_one_box_per_parcel(tmp_path: Path) -> None:
|
||||
full = {"pnu": "A", "jibun": "95전", "jimok": "전", "parea": 4916, "owner_nm": "개인"}
|
||||
split = {"pnu": "B", "jibun": "산1임", "jimok": "임야", "parea": 12.4, "owner_nm": ""}
|
||||
parcels = [
|
||||
{"ring": _square(0.0, 40.0), "props": full},
|
||||
# 도곽에 잘려 두 조각이 된 필지 — 정보는 큰 조각에 한 번만
|
||||
{"ring": _square(100.0, 10.0), "props": split},
|
||||
{"ring": _square(200.0, 30.0), "props": split},
|
||||
# 도곽을 통째로 감싼 필지 — 경계 없이 정보만
|
||||
{"ring": [], "props": {"pnu": "C", "jibun": "산77"}, "label_at": (500.0, 500.0)},
|
||||
]
|
||||
drawing = build_landuse_drawing("landuse", "용지도", [], [], parcels, [], [])
|
||||
texts = [e for e in drawing["entities"] if e.get("layerId") == JIBUN_LAYER_ID]
|
||||
labels = [e["shapeData"]["label"] for e in texts]
|
||||
assert labels == ["95전", "전", "4,916㎡", "개인", "산1임", "임야", "12㎡", "산77"]
|
||||
groups = {round(e["shapeData"]["basePoint"]["x"], 6) for e in texts}
|
||||
assert len(groups) == 3
|
||||
# 쪼개진 필지는 큰 조각(200~230 m) 가운데에
|
||||
split_x = {
|
||||
e["shapeData"]["basePoint"]["x"] for e in texts if e["shapeData"]["label"] == "산1임"
|
||||
two_parts = {
|
||||
"type": "MultiPolygon",
|
||||
"coordinates": [[_square(250.0, 200.0, 20.0)], [_square(300.0, 150.0, 80.0)]],
|
||||
}
|
||||
assert split_x.pop() == pytest.approx(215.0 * MM)
|
||||
path = _geojson(tmp_path, [(full, _polygon(_square(20.0, 20.0, 120.0))), (split, two_parts)])
|
||||
parcels = clip_parcels(path, "EPSG:4326", BOX)
|
||||
items = place_labels(parcels, [], BOX)
|
||||
assert sorted(item["lines"] for item in items) == [
|
||||
["95전", "전", "4,916㎡", "개인"],
|
||||
["산1임", "임야", "12㎡", ""],
|
||||
]
|
||||
# 쪼개진 필지는 큰 조각(300~380 · 150~230)에 지시선 끝
|
||||
(b,) = [item for item in items if item["lines"][0] == "산1임"]
|
||||
assert 300.0 <= b["anchor"][0] <= 380.0 and 150.0 <= b["anchor"][1] <= 230.0
|
||||
# 빈 줄은 글 없이 자리만 — 도면 글은 일곱
|
||||
drawing = build_plan_kind_drawing(
|
||||
"plan_landuse", "평면도(용지도)", BOX, {"parcel_info": {"items": items}}
|
||||
)
|
||||
texts = [
|
||||
e["shapeData"]["label"]
|
||||
for e in drawing["entities"]
|
||||
if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text"
|
||||
]
|
||||
assert sorted(texts) == sorted(["95전", "전", "4,916㎡", "개인", "산1임", "임야", "12㎡"])
|
||||
|
||||
|
||||
def test_zone_inside_one_parcel_is_named_once(tmp_path) -> None:
|
||||
"""작은 구역이 큰 필지 하나 안에 통째로 — 경계선이 없어도 어느 필지인지 한 번 적는다 (68-2)."""
|
||||
import json
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_parcels
|
||||
|
||||
def test_zone_inside_one_parcel_is_named_once(tmp_path: Path) -> None:
|
||||
props = {
|
||||
"pnu": "P1",
|
||||
"sgg_nm": "봉화군",
|
||||
@@ -53,27 +94,70 @@ def test_zone_inside_one_parcel_is_named_once(tmp_path) -> None:
|
||||
"parea": 1234567,
|
||||
"owner_nm": "국",
|
||||
}
|
||||
big = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]]
|
||||
geojson = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": props,
|
||||
"geometry": {"type": "Polygon", "coordinates": [big]},
|
||||
}
|
||||
],
|
||||
}
|
||||
path = tmp_path / "parcels.geojson"
|
||||
path.write_text(json.dumps(geojson, ensure_ascii=False), encoding="utf-8")
|
||||
parcels = _clip_parcels(path, "EPSG:4326", (4.0, 4.0, 6.0, 6.0))
|
||||
assert len(parcels) == 1 and parcels[0]["ring"] == []
|
||||
|
||||
route = [(4.5, 4.5), (5.5, 5.5)]
|
||||
drawing = build_landuse_drawing("landuse", "용지도", route, [], parcels, [], [])
|
||||
labels = [
|
||||
e["shapeData"]["label"] for e in drawing["entities"] if e.get("layerId") == JIBUN_LAYER_ID
|
||||
path = _geojson(tmp_path, [(props, _polygon(_square(-500.0, -500.0, 2000.0)))])
|
||||
parcels = clip_parcels(path, "EPSG:4326", BOX)
|
||||
assert len(parcels) == 1 and parcels[0]["lines"] == [] and parcels[0]["whole"]
|
||||
(item,) = place_labels(parcels, [(150.0, 100.0), (250.0, 200.0)], BOX)
|
||||
assert item["lines"] == [
|
||||
"구역 전체가 이 필지 안 (봉화군 재산면 갈산리)",
|
||||
"산77-1임",
|
||||
"임야",
|
||||
"1,234,567㎡",
|
||||
"국",
|
||||
]
|
||||
notes = [label for label in labels if label.startswith("구역 전체가 이 필지 안")]
|
||||
assert notes == ["구역 전체가 이 필지 안 (봉화군 재산면 갈산리)"]
|
||||
assert labels[1:] == ["산77-1임", "임야", "1,234,567㎡", "국"]
|
||||
|
||||
|
||||
def test_boxes_clear_route_and_each_other(tmp_path: Path) -> None:
|
||||
# 노선이 가운데를 가로지르는 필지 열둘(4 × 3)
|
||||
features = [
|
||||
(
|
||||
{
|
||||
"pnu": f"P{i}{j}",
|
||||
"jibun": f"{i}{j}-1",
|
||||
"jimok": "임야",
|
||||
"parea": 9999,
|
||||
"owner_nm": "홍길동",
|
||||
},
|
||||
_polygon(_square(i * 100.0, j * 100.0, 100.0)),
|
||||
)
|
||||
for i in range(4)
|
||||
for j in range(3)
|
||||
]
|
||||
path = _geojson(tmp_path, features)
|
||||
route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)]
|
||||
items = place_labels(clip_parcels(path, "EPSG:4326", BOX), route, BOX)
|
||||
assert len(items) == 12
|
||||
clear = LineString(route).buffer(ROUTE_CLEAR_M)
|
||||
rects = [_rect(item) for item in items]
|
||||
for item, rect in zip(items, rects):
|
||||
assert not box_polygon(*rect).intersects(clear) # 상자 · 노선 겹침 0
|
||||
assert not clear.contains(Point(item["anchor"])) # 지시선 끝도 노선 밖
|
||||
assert BOX[0] <= rect[0] and rect[2] <= BOX[2] and BOX[1] <= rect[1] and rect[3] <= BOX[3]
|
||||
for index, rect in enumerate(rects):
|
||||
for other in rects[index + 1 :]:
|
||||
assert not box_polygon(*rect).intersects(box_polygon(*other))
|
||||
# 도면(종이 mm)에서도 상자 테두리가 중심선과 안 닿음
|
||||
pieces = {
|
||||
"centerline": {"lines": [[list(p) for p in route]]},
|
||||
"parcel_info": {"items": items},
|
||||
}
|
||||
drawing = build_plan_kind_drawing("plan_landuse", "평면도(용지도)", BOX, pieces)
|
||||
(center,) = [e for e in drawing["entities"] if e.get("layerId") == layer_id("centerline")]
|
||||
center_line = _polyline(center)
|
||||
# 상자 = 변 넷 · 지시선 = 계단 둘
|
||||
outlines = [
|
||||
e
|
||||
for e in drawing["entities"]
|
||||
if e.get("layerId") == layer_id("parcel_info") and len(e.get("children") or []) == 4
|
||||
]
|
||||
assert len(outlines) == 12
|
||||
for outline in outlines:
|
||||
assert _polyline(outline).distance(center_line) > 0
|
||||
|
||||
|
||||
def _polyline(entity: dict) -> LineString:
|
||||
children = entity["children"]
|
||||
points = [children[0]["shapeData"]["startPoint"]] + [
|
||||
c["shapeData"]["endPoint"] for c in children
|
||||
]
|
||||
return LineString([(p["x"], p["y"]) for p in points])
|
||||
|
||||
@@ -6,21 +6,31 @@
|
||||
④ 고른 조각만 도면 층으로 얹힘
|
||||
⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 ·
|
||||
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호
|
||||
⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층
|
||||
⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import (
|
||||
curve_values,
|
||||
dms,
|
||||
ip_blocks,
|
||||
ip_table_piece,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
DEFAULT_KINDS,
|
||||
build_plan_kind_drawing,
|
||||
layer_id,
|
||||
slope_pieces,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import drawing_to_dxf
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
_kinds_dir,
|
||||
@@ -123,7 +133,13 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No
|
||||
|
||||
def test_kinds_add_delete_persist(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
assert [k["name"] for k in list_kinds(root)] == ["지형", "노선배치도", "대피소", "라이다"]
|
||||
assert [k["name"] for k in list_kinds(root)] == [
|
||||
"지형",
|
||||
"노선배치도",
|
||||
"대피소",
|
||||
"라이다",
|
||||
"용지도",
|
||||
]
|
||||
added = add_kind(root, "내 평면도", ["centerline", "station", "nope"])
|
||||
assert added["pieces"] == ["centerline", "station"]
|
||||
with pytest.raises(ValueError):
|
||||
@@ -132,14 +148,14 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None:
|
||||
add_kind(root, "빈 것", [])
|
||||
delete_kind(root, "terrain") # 기본 넷도 지움
|
||||
again = list_kinds(root) # 다시 읽어 같음
|
||||
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "내 평면도"]
|
||||
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "용지도", "내 평면도"]
|
||||
assert again[-1]["pieces"] == ["centerline", "station"]
|
||||
with pytest.raises(FileNotFoundError):
|
||||
delete_kind(root, "terrain")
|
||||
for kind in list(again):
|
||||
delete_kind(root, kind["id"])
|
||||
assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음)
|
||||
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar"}
|
||||
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar", "landuse"}
|
||||
|
||||
|
||||
def test_refuge_marks_same_spot(tmp_path: Path) -> None:
|
||||
@@ -184,16 +200,16 @@ def test_split_runs_switch_cut_fill_and_stay_joined() -> None:
|
||||
def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
outline = {"road_edge", "fill_outline", "cut_outline"}
|
||||
for (
|
||||
kind
|
||||
) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음
|
||||
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
|
||||
# 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로)
|
||||
for kind in DEFAULT_KINDS:
|
||||
if kind["id"] != "landuse":
|
||||
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
|
||||
folder = _kinds_dir(root)
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "old.json").write_text(
|
||||
json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8"
|
||||
)
|
||||
(kind,) = list_kinds(root)
|
||||
kind = next(k for k in list_kinds(root) if k["id"] == "old") # 용지도는 이관으로 더해짐
|
||||
assert set(kind["pieces"]) == outline | {"station"}
|
||||
|
||||
|
||||
@@ -230,3 +246,101 @@ def test_drainage_symbol_piece() -> None:
|
||||
mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")]
|
||||
assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2
|
||||
assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine)
|
||||
|
||||
|
||||
def test_ip_table_example_values_and_dms() -> None:
|
||||
ia = 94 + 59 / 60 + 58.38 / 3600 # 예시 그림 IP1
|
||||
values = curve_values(15.0, ia)
|
||||
assert dms(ia) == "94-59-58.38"
|
||||
assert [f"{values[k]:.5f}" for k in ("tl", "cl", "sl")] == ["16.36950", "24.87082", "7.20271"]
|
||||
assert dms(1 + 59 / 60 + 59.9999 / 3600) == "2-00-00.00" # 초 60.00 올림
|
||||
assert dms(5 + 3 / 60 + 7.2 / 3600) == "5-03-07.20"
|
||||
|
||||
|
||||
# ㄱ자 노선 — (0,0)→(100,0)→(100,100) · 모서리 R 15 곡선(가운데 누가거리 100) · 앞에 노선 밖 옛 곡선
|
||||
_ROUTE = [(0.0, 0.0, 0.0), (85.0, 85.0, 0.0), (115.0, 100.0, 15.0), (200.0, 100.0, 100.0)]
|
||||
_CURVES = [
|
||||
{"apex": [500.0, 500.0], "start": [490.0, 500.0], "end": [500.0, 510.0], "radius_m": 10.0},
|
||||
{"apex": [100.0, 0.0], "start": [85.0, 0.0], "end": [100.0, 15.0], "radius_m": 15.0},
|
||||
]
|
||||
|
||||
|
||||
def test_ip_blocks_one_sheet_each_and_ends() -> None:
|
||||
first = ip_blocks(_CURVES, _ROUTE, 1, 2, 0.0, 100.0)
|
||||
second = ip_blocks(_CURVES, _ROUTE, 2, 2, 100.0, 200.0)
|
||||
assert [b["name"] for b in first] == ["BP"] # 경계 IP 는 뒤 장에만
|
||||
assert [b["name"] for b in second] == ["2", "EP"] # 번호 = 곡선표 차례(옛 곡선은 빠짐)
|
||||
ip = second[0]
|
||||
assert dms(ip["ia"]) == "90-00-00.00" and ip["tl"] == pytest.approx(15.0)
|
||||
assert [b["name"] for b in ip_blocks(_CURVES, _ROUTE, 1, 1, 0.0, 200.0)] == ["BP", "2", "EP"]
|
||||
|
||||
|
||||
def test_ip_table_drawing_clips_lines_under_table(tmp_path: Path) -> None:
|
||||
box = (-20.0, -20.0, 300.0, 200.0)
|
||||
contour = [[-20.0, 0.0], [300.0, 0.0]] # 표(왼쪽 아래 · 높이 48 m) 밑을 지나는 등고선
|
||||
pieces = {
|
||||
"contour": {"lines": [contour]},
|
||||
"centerline": {"lines": [[[0.0, 100.0], [9.0, 100.0]]]},
|
||||
}
|
||||
pieces["ip_table"] = ip_table_piece(
|
||||
pieces, _CURVES, _ROUTE, box, {"number": 1, "start_m": 0.0, "end_m": 200.0}, 1
|
||||
)
|
||||
# IP 표 칸(x 29.2 ~ 72.4 · y -14 ~ 34)만 빠짐 · BP · EP 칸(3행)은 y 0 위라 안 자름
|
||||
assert pieces["contour"]["lines"] == [[[-20.0, 0.0], [29.2, 0.0]], [[72.4, 0.0], [300.0, 0.0]]]
|
||||
assert pieces["centerline"]["lines"] == [[[0.0, 100.0], [9.0, 100.0]]] # 표 밖은 그대로
|
||||
drawing = build_plan_kind_drawing("plan_ip", "시험", box, pieces)
|
||||
mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("ip_table")]
|
||||
assert [e["type"] for e in mine] == ["Table"] * 3
|
||||
bp, ip, _ep = (e["shapeData"] for e in mine)
|
||||
assert len(bp["rowHeights"]) == 3 and len(ip["rowHeights"]) == 8
|
||||
assert bp["cells"][1][2]["text"] == "0.00000" and ip["cells"][1][2]["text"] == "0.00000"
|
||||
assert ip["cells"][2][2]["text"] == "100.00000" # Y = 동(x)
|
||||
assert ip["cells"][3][1]["text"] == "90-00-00.00"
|
||||
assert {layer["id"] for layer in drawing["layers"]} >= {layer_id("ip_table")}
|
||||
assert "ip_table" in next(k for k in DEFAULT_KINDS if k["id"] == "route")["pieces"]
|
||||
# DXF — 표 → 선 + 글(IP 묶음: 바깥 4 · 세로 10 · 가로 20 · 글 17)
|
||||
raw, skipped = drawing_to_dxf({"entities": [mine[1]], "layers": drawing["layers"]})
|
||||
(tmp_path / "ip.dxf").write_bytes(raw)
|
||||
space = ezdxf.readfile(tmp_path / "ip.dxf").modelspace()
|
||||
assert skipped == {} and len(space.query("LINE")) == 34 and len(space.query("TEXT")) == 17
|
||||
assert {"90-00-00.00", "15.00000"} <= {t.dxf.text for t in space.query("TEXT")}
|
||||
|
||||
|
||||
def test_landuse_kind_added_once_to_old_folder(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
folder = _kinds_dir(root)
|
||||
folder.mkdir(parents=True)
|
||||
for order, kind in enumerate(DEFAULT_KINDS[:4]): # 93장 앞에 내려 둔 기본 넷
|
||||
(folder / f"{kind['id']}.json").write_text(
|
||||
json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8"
|
||||
)
|
||||
kinds = list_kinds(root)
|
||||
assert [k["id"] for k in kinds][-1] == "landuse"
|
||||
assert kinds[-1]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"]
|
||||
assert len(list_kinds(root)) == 5 # 두 번 읽어도 한 번
|
||||
delete_kind(root, "landuse")
|
||||
assert "landuse" not in [k["id"] for k in list_kinds(root)] # 지운 것은 안 살아남
|
||||
|
||||
|
||||
def test_landuse_pieces_become_layers() -> None:
|
||||
pieces = {
|
||||
"parcel": {"lines": [[[0.0, 0.0], [10.0, 0.0]]]},
|
||||
"emd": {"lines": [[[0.0, 5.0], [10.0, 5.0]]]},
|
||||
"sgg": {"lines": [[[0.0, 9.0], [10.0, 9.0]]]},
|
||||
"parcel_info": {
|
||||
"items": [
|
||||
{"lines": ["1-1", "임야", "", "국"], "anchor": [5.0, 2.0], "center": [20.0, 20.0]}
|
||||
]
|
||||
},
|
||||
}
|
||||
drawing = build_plan_kind_drawing(
|
||||
"plan_landuse", "평면도(용지도)", (-50.0, -10.0, 50.0, 50.0), pieces
|
||||
)
|
||||
names = {layer["id"] for layer in drawing["layers"]}
|
||||
assert {layer_id(p) for p in ("parcel", "parcel_info", "emd", "sgg")} <= names
|
||||
labels = [
|
||||
e["shapeData"]["label"]
|
||||
for e in drawing["entities"]
|
||||
if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text"
|
||||
]
|
||||
assert labels == ["1-1", "임야", "국"]
|
||||
|
||||
@@ -92,3 +92,12 @@ def test_빈_칸_글_풍선_꾸밈_옵션(r):
|
||||
assert '<td class="is-empty"></td>' in o # empty="" — 「-」 대신 빈칸
|
||||
assert '<th scope="col" rowspan="1" class="is-missing" title="풍선">메쌓기</th>' in o
|
||||
assert 'rowspan="2" class="is-frozen is-edge" style="left:0px" title="측점">측점</th>' in o
|
||||
|
||||
|
||||
def test_줄_누름_옵션일_때만_data_i(r):
|
||||
# 95-3 — onRowClick 을 주면 본문 줄에만 data-i · is-clickable(합계 줄 제외) · 안 주면 글 그대로
|
||||
pick = r["pick"]
|
||||
assert '<tr class="is-clickable" data-i="0">' in pick
|
||||
assert '<tr class="is-alt is-clickable" data-i="1">' in pick
|
||||
assert pick.count("data-i=") == 2
|
||||
assert "data-i" not in r["opts"] and "is-clickable" not in r["foot"]
|
||||
|
||||
@@ -133,3 +133,7 @@
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.ui-data-table__table tbody tr.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface DataTableOptions {
|
||||
caption?: string;
|
||||
/** 빈 값 글(기본 「-」 · 사용자가 채울 칸은 "") */
|
||||
empty?: string;
|
||||
/** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */
|
||||
onRowClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface DataTable {
|
||||
@@ -112,6 +114,7 @@ function rowHtml(
|
||||
r: DataRow,
|
||||
alt = false,
|
||||
blank = EMPTY,
|
||||
index: number | null = null,
|
||||
): string {
|
||||
let tr = "";
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
@@ -126,7 +129,9 @@ function rowHtml(
|
||||
(fmts[j] ? ' data-num=""' : "");
|
||||
tr += `<td${attrs}>${esc(txt)}</td>`;
|
||||
}
|
||||
return alt ? `<tr class="is-alt">${tr}</tr>` : `<tr>${tr}</tr>`;
|
||||
const cls = [alt && "is-alt", index !== null && "is-clickable"].filter(Boolean).join(" ");
|
||||
const data = index !== null ? ` data-i="${index}"` : "";
|
||||
return cls || data ? `<tr${cls ? ` class="${cls}"` : ""}${data}>${tr}</tr>` : `<tr>${tr}</tr>`;
|
||||
}
|
||||
|
||||
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
|
||||
@@ -163,7 +168,9 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
const to = win ? Math.max(from, Math.min(win.to, n)) : n;
|
||||
let body = win ? pad(from * win.rowH, cols.length) : "";
|
||||
const blank = o.empty ?? EMPTY;
|
||||
for (let i = from; i < to; i++) body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1, blank);
|
||||
const pick = o.onRowClick !== undefined;
|
||||
for (let i = from; i < to; i++)
|
||||
body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1, blank, pick ? i : null);
|
||||
if (win) body += pad((n - to) * win.rowH, cols.length);
|
||||
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
|
||||
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
|
||||
@@ -199,6 +206,10 @@ export function createDataTable(opts: DataTableOptions): DataTable {
|
||||
}
|
||||
};
|
||||
root.addEventListener("scroll", () => draw(false), { passive: true });
|
||||
root.addEventListener("click", (event) => {
|
||||
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
if (row && root.contains(row)) state.onRowClick?.(Number(row.dataset.i));
|
||||
});
|
||||
draw(true);
|
||||
return {
|
||||
root,
|
||||
|
||||
@@ -611,7 +611,6 @@ export const ui_locales_b2 = {
|
||||
B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"],
|
||||
B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"],
|
||||
B08_Quantity_Tab_StructureSummary: ["구조물 집계표", "Structure Summary"],
|
||||
B08_Quantity_Tab_StructureSheet: ["구조물도", "Structure Sheets"],
|
||||
B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"],
|
||||
B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"],
|
||||
B08_Quantity_Tab_Preparation: ["준비공·사방공", "Preparation & Erosion Control"],
|
||||
|
||||
@@ -19,6 +19,17 @@ export const ui_locales_b4 = {
|
||||
],
|
||||
B08_Quantity_Tab_Hydrology: ["수리집수면적표", "Hydraulic Catchment Table"],
|
||||
B08_Quantity_Tab_Pending: ["차례대로 만들 탭입니다.", "This tab comes later, in order."],
|
||||
B08_Quantity_Tab_Structure: ["구조물", "Structure"],
|
||||
B08_Quantity_Tab_MaterialTotal: ["총괄자재수량집계표", "Total Material Quantity Table"],
|
||||
B08_Quantity_Tab_ConcreteMaterial: [
|
||||
"콘크리트소요자재집계표",
|
||||
"Concrete Material Requirement Table",
|
||||
],
|
||||
B08_Quantity_Tab_MaterialQuantity: ["자재수량집계표", "Material Quantity Table"],
|
||||
B08_Quantity_Sheets_Title: ["구조물도", "Structure Sheets"],
|
||||
B08_Quantity_Sheets_FromCopy: ["프로젝트 복사본", "Project copy"],
|
||||
B08_Quantity_Sheets_FromMaster: ["M02 원본", "M02 original"],
|
||||
B08_Quantity_Sheets_NoCopy: ["복사본에 없음", "Not in the project copy"],
|
||||
B08_Quantity_Drawing_Loading: ["구조물도를 불러오는 중…", "Loading structure sheets…"],
|
||||
B08_Quantity_Drawing_Failed: [
|
||||
"구조물도를 불러오지 못했습니다",
|
||||
|
||||
Reference in New Issue
Block a user