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:
2026-10-08 14:01:26 +09:00
49 changed files with 2056 additions and 943 deletions
+2 -2
View File
@@ -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 []
+9 -2
View File
@@ -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():
+14 -10
View File
@@ -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;
}
+12 -6
View File
@@ -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
),
};
}
@@ -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,
}
);
});
}
+68 -13
View File
@@ -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]:
+18 -2
View File
@@ -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")
+12 -6
View File
@@ -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,
+33
View File
@@ -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]> = [];
+36 -5
View File
@@ -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);
}
}
+146 -103
View File
@@ -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}`));
}
})();
},
});
}
+14 -7
View File
@@ -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;
}
+25 -5
View File
@@ -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`, {
+61 -1
View File
@@ -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;
+1 -1
View File
@@ -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)과 같은 값 */
}
+55 -20
View File
@@ -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"),
+39 -6
View File
@@ -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
+11
View File
@@ -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]
+19 -1
View File
@@ -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
+141 -57
View File
@@ -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])
+122 -8
View File
@@ -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", "임야", "국"]
+9
View File
@@ -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"]
+4
View File
@@ -133,3 +133,7 @@
color: var(--color-text);
font-weight: var(--font-weight-bold);
}
.ui-data-table__table tbody tr.is-clickable {
cursor: pointer;
}
+13 -2
View File
@@ -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,
-1
View File
@@ -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"],
+11
View File
@@ -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: [
"구조물도를 불러오지 못했습니다",