Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -7,6 +7,9 @@
|
||||
* (`_Engine_Cad_Table.cross_note_cad_entities`).
|
||||
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수
|
||||
* 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면.
|
||||
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `culvertNoteEntities` ·
|
||||
* `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 측점은 B07 이 그것으로 그림 ·
|
||||
* 거울 시험 `test_260_note_cad.py`.
|
||||
* 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
|
||||
* ========================================================================== */
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""B07 횡단도 표 위 CAD 칸 — 횡단배수 미리 넣음(260-4 · 요구 162-21).
|
||||
|
||||
⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `culvertNoteEntities` ·
|
||||
`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py`.
|
||||
B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 그림을 놓음 · 사용자가 지운 칸
|
||||
(빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저
|
||||
(모달이 처음 열 때 옮기는 차례와 같음).
|
||||
좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file
|
||||
from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points
|
||||
|
||||
# TS `PIPE_MATCH_M`(`design_selection`) — 관 자리와 측점이 같은 자리로 보는 폭
|
||||
PIPE_MATCH_M = 0.51
|
||||
_LAYER_ID = "기본"
|
||||
_FONT = 2.0
|
||||
_LINE = 3.0
|
||||
_COLOR = "#ff7f7f"
|
||||
_BASIN_FORMS = {"I": "I형", "L": "L형", "U": "ㄷ형"}
|
||||
|
||||
|
||||
def _js_round(value: float) -> int:
|
||||
"""JS `Math.round` — .5 는 위로."""
|
||||
return math.floor(value + 0.5)
|
||||
|
||||
|
||||
def _one(value: Any) -> str:
|
||||
ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||
return f"{value:.1f}" if ok else ""
|
||||
|
||||
|
||||
def _metres(value: Any) -> str:
|
||||
return f"{_one(value)}m" if _one(value) else ""
|
||||
|
||||
|
||||
def _trim(value: float) -> str:
|
||||
rounded = _js_round(value * 100) / 100
|
||||
return str(int(rounded)) if rounded == int(rounded) else str(rounded)
|
||||
|
||||
|
||||
def _bond(form: Any) -> str:
|
||||
"""돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로."""
|
||||
text = str(form) if form is not None else ""
|
||||
return "찰" if "찰" in text else "메" if "메" in text else text
|
||||
|
||||
|
||||
def _side_line(side: Any, title: str) -> str | None:
|
||||
if not isinstance(side, dict) or not side.get("structure"):
|
||||
return None
|
||||
basin = side["structure"] == "집수정"
|
||||
form = (
|
||||
_BASIN_FORMS.get(side.get("basin_form") or "", "")
|
||||
if basin
|
||||
else _bond(side.get("revet_form"))
|
||||
)
|
||||
height = side.get("basin_height_m" if basin else "revet_height_m")
|
||||
length = side.get("basin_length_m" if basin else "revet_length_m")
|
||||
spec = ",".join(part for part in (form, f"H={_one(height)}") if part)
|
||||
return f"관보호공({title}) {side['structure']}({spec}) L={_metres(length)}"
|
||||
|
||||
|
||||
def _area(value: Any) -> float:
|
||||
"""JS `Number()` 꼴 — 빈 글 = 0 · 못 읽음 = nan."""
|
||||
if value is None:
|
||||
return math.nan
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return 0.0
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return math.nan
|
||||
|
||||
|
||||
def _chute_line(options: dict[str, Any], fallback: str | None) -> str | None:
|
||||
picked = sorted(
|
||||
int(hit.group(1))
|
||||
for key, value in options.items()
|
||||
if (hit := re.match(r"^chute_add_(\d+)_structure$", key))
|
||||
and str(value if value is not None else "").strip()
|
||||
)
|
||||
head = f"chute_add_{picked[0]}_" if picked else None
|
||||
structure = str(options[f"{head}structure"]).strip() if head else fallback
|
||||
if not structure:
|
||||
return None
|
||||
bond_raw = options.get(f"{head}bond") if head else None
|
||||
bond = _bond(bond_raw if bond_raw is not None else "") if head else ""
|
||||
area = _area(options.get(f"{head}area")) if head else math.nan
|
||||
spec = ",".join(part for part in (structure, bond) if part)
|
||||
return f"관유출부보강({spec}) {_trim(area) if area > 0 else ''}m2"
|
||||
|
||||
|
||||
def station_no_label(chainage_m: float, interval_m: float) -> str:
|
||||
"""측점 이름(대문자 NO · 거리 정수) — TS `stationNoLabel` 짝."""
|
||||
safe = interval_m if interval_m > 0 else 1.0
|
||||
number = math.floor((chainage_m + 1e-6) / safe)
|
||||
rest = chainage_m - number * safe
|
||||
if rest >= safe - 0.05:
|
||||
number += 1
|
||||
rest = 0.0
|
||||
return f"NO.{number}" if abs(rest) < 0.05 else f"NO.{number}+{_js_round(rest)}"
|
||||
|
||||
|
||||
def _shape(index: int, kind: str, shape_data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": f"culvert-note:{index}",
|
||||
"type": kind,
|
||||
"lineColor": _COLOR,
|
||||
"lineWidth": 1,
|
||||
"layerId": _LAYER_ID,
|
||||
"shapeData": shape_data,
|
||||
}
|
||||
|
||||
|
||||
def _text(index: int, label: str, x: float, y: float, align: str = "left") -> dict[str, Any]:
|
||||
return _shape(
|
||||
index,
|
||||
"Text",
|
||||
{
|
||||
"label": label,
|
||||
"basePoint": {"x": x, "y": y},
|
||||
"options": {
|
||||
"textDirection": {"x": 1, "y": 0},
|
||||
"textAlign": align,
|
||||
"textColor": _COLOR,
|
||||
"fontSize": _FONT,
|
||||
"fontFamily": "sans-serif",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def culvert_note_entities(
|
||||
chainage_m: float,
|
||||
culvert: dict[str, Any] | None,
|
||||
design: dict[str, Any] | None,
|
||||
station: str,
|
||||
options: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""횡단배수 미리 넣음 — 이 측점에 놓인(주인) 배수관만 · 상자형 · 숨은 관 · 이웃 = 빈 목록.
|
||||
|
||||
`culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `options` = 그 관의 `pipe_points` 옵션.
|
||||
"""
|
||||
if not isinstance(culvert, dict) or culvert.get("type") != "pipe":
|
||||
return []
|
||||
if culvert.get("body") or culvert.get("hidden_pipe"):
|
||||
return []
|
||||
owner = culvert.get("chainage_m")
|
||||
if abs((owner if isinstance(owner, (int, float)) else chainage_m) - chainage_m) >= PIPE_MATCH_M:
|
||||
return []
|
||||
cx, cy, radius = 6.5, -9.0, 6.5
|
||||
length = design.get("pipe_length_m") if isinstance(design, dict) else None
|
||||
inside = [
|
||||
"배수관",
|
||||
f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m",
|
||||
f"L={_metres(length)}",
|
||||
]
|
||||
chute = culvert.get("chute") if isinstance(culvert.get("chute"), dict) else {}
|
||||
right = [
|
||||
line
|
||||
for line in (
|
||||
station,
|
||||
_side_line(culvert.get("inlet"), "유입"),
|
||||
_side_line(culvert.get("outlet"), "유출"),
|
||||
_chute_line(options or {}, chute.get("structure")),
|
||||
)
|
||||
if line
|
||||
]
|
||||
entities = [
|
||||
_shape(0, "Circle", {"center": {"x": cx, "y": cy}, "radius": radius}),
|
||||
_shape(
|
||||
1,
|
||||
"Line",
|
||||
{
|
||||
"startPoint": {"x": cx, "y": cy - radius},
|
||||
"endPoint": {"x": cx, "y": cy - radius - _LINE},
|
||||
},
|
||||
),
|
||||
]
|
||||
entities += [
|
||||
_text(2 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside)
|
||||
]
|
||||
entities += [_text(5 + i, line, 15, -_LINE * (i + 0.5)) for i, line in enumerate(right)]
|
||||
return entities
|
||||
|
||||
|
||||
def load_pipe_notes(project_root: Path) -> list[tuple[float, dict[str, Any], dict[str, Any]]]:
|
||||
"""정본 `pipe_points.json` 의 배수관 — (관 누가거리, 세트, 옵션) · 관경 안 고름 = 관 없음."""
|
||||
path = pipe_points_file(project_root)
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
pipes = []
|
||||
for point in parse_pipe_points(document.get("points")):
|
||||
options = dict(point.options or {})
|
||||
if point.facility != PIPE_FACILITY_PIPE or options.get("pipe_diameter_mm") in (None, ""):
|
||||
continue
|
||||
spec = {**_culvert_set(options), "chainage_m": float(point.chainage_m)}
|
||||
pipes.append((float(point.chainage_m), spec, options))
|
||||
return pipes
|
||||
|
||||
|
||||
def with_culvert_note(
|
||||
design: dict[str, Any] | None,
|
||||
chainage_m: float,
|
||||
pipes: list[tuple[float, dict[str, Any], dict[str, Any]]],
|
||||
station: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""CAD 칸이 없고 옛 글도 없는 주인 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로.
|
||||
|
||||
설계가 없는 측점(dict 아님)은 그대로 — 표 · 계획선 셈이 설계 없음으로 가는 길을 안 바꿈.
|
||||
"""
|
||||
if not isinstance(design, dict) or design.get("structure_note_cad") is not None:
|
||||
return design
|
||||
if str(design.get("structure_note") or "").strip():
|
||||
return design
|
||||
near = [pipe for pipe in pipes if abs(pipe[0] - chainage_m) < PIPE_MATCH_M]
|
||||
if not near:
|
||||
return design
|
||||
_at, spec, options = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))
|
||||
entities = culvert_note_entities(chainage_m, spec, design, station, options)
|
||||
if not entities:
|
||||
return design
|
||||
return {**design, "structure_note_cad": {"entities": entities}}
|
||||
@@ -25,6 +25,13 @@ 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_CulvertNote import (
|
||||
load_pipe_notes,
|
||||
with_culvert_note,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
|
||||
station_no_label as station_no_label_upper,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
@@ -383,17 +390,22 @@ def _cross_section_input(
|
||||
chainage: int | float,
|
||||
design: dict[str, Any] | None,
|
||||
overlay: tuple[float, float, float, float] | None = None,
|
||||
pipes: list[tuple[float, dict[str, Any], dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
|
||||
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다.
|
||||
|
||||
`pipes` = 정본 배수관(`load_pipe_notes`) — CAD 칸 없는 주인 측점에 미리 넣음(260-4)."""
|
||||
path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage)
|
||||
if not path.is_file():
|
||||
return None
|
||||
source = _read_json(path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
at = float(source.get("chainage_m", chainage))
|
||||
return {
|
||||
"chainage": chainage,
|
||||
"source": source,
|
||||
"design": design,
|
||||
# 표 · 계획선은 저장 설계 그대로 · 그림만 미리 넣음 칸을 얹은 사본(260-4)
|
||||
"design": with_culvert_note(design, at, pipes or [], station_no_label_upper(at, interval)),
|
||||
"design_line": _cross_design_line(longitudinal_path, source, design),
|
||||
"quantity_table": _quantity_table(source, design),
|
||||
"title": station_no_label(float(source.get("chainage_m", chainage)), interval),
|
||||
@@ -416,6 +428,7 @@ def _read_cross_sheet(
|
||||
if sheet is None:
|
||||
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
pipes = load_pipe_notes(project_root)
|
||||
sections = [
|
||||
section
|
||||
for section in (
|
||||
@@ -425,6 +438,7 @@ def _read_cross_sheet(
|
||||
chainage,
|
||||
designs.get(chainage),
|
||||
overlays.get(chainage),
|
||||
pipes,
|
||||
)
|
||||
for chainage in sheet["chainages"]
|
||||
)
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface DiagramBox {
|
||||
notes?: string[];
|
||||
/** 호버 = 줄이지 않은 주소 */
|
||||
tip?: string;
|
||||
/** 장소 그림 자리(254-3 · 글 아래 · 그림 없으면 없음) */
|
||||
pic?: Rect;
|
||||
}
|
||||
|
||||
export interface DiagramLine {
|
||||
@@ -101,6 +103,8 @@ const KM_LINE = 16;
|
||||
/** 상자 안 글 줄 간격 · 위아래 여백(254 상자 높이 = 줄 × ROW + ROW_PAD · 최소 BOX.h) */
|
||||
export const ROW = 15;
|
||||
const ROW_PAD = 13;
|
||||
/** 장소 그림(254-3) — 상자 안쪽 폭 · 서버 그림 480 × 320 과 같은 3:2 */
|
||||
export const PIC = { w: 132, h: 88 };
|
||||
const NOTE_LINES = 3;
|
||||
|
||||
/** 선 길이(254) — 구간 km 비례(로그) · 최소 · 최대(너무 멀어지지 않게) · 잇는 곳 테두리 → 상자 테두리 */
|
||||
@@ -421,8 +425,10 @@ export function layoutMilestone(
|
||||
mid_address?: string | null;
|
||||
} | null,
|
||||
midKm: number,
|
||||
pics: ReadonlySet<string> = new Set(), // 254-3 장소 그림 있는 상자 id
|
||||
): DiagramLayout {
|
||||
const boxes: DiagramBox[] = [];
|
||||
const picH = (id: string): number => (pics.has(id) ? PIC.h + INSET : 0);
|
||||
const pending: Array<[string, string, Leg[]]> = [];
|
||||
const byId = new Map<string, DiagramBox>();
|
||||
const center = (id: string): [number, number] => {
|
||||
@@ -523,7 +529,7 @@ export function layoutMilestone(
|
||||
const [ax, ay] = center(anchor);
|
||||
const [ux, uy] = unit(deg);
|
||||
const notes = b.address ? wrap(b.address, BOX.w - 2 * INSET, FONT.note) : [];
|
||||
const h = boxH(1 + notes.length);
|
||||
const h = boxH(1 + notes.length) + picH(b.id);
|
||||
const reach = reachOf(anchor, h, ux, uy, b.legs);
|
||||
put(
|
||||
{
|
||||
@@ -583,7 +589,7 @@ export function layoutMilestone(
|
||||
// 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음
|
||||
linked: r.linked !== false && (r.to === "site" || anchor === r.to),
|
||||
notes,
|
||||
h: boxH((items ? 1 : 0) + 1 + notes.length),
|
||||
h: boxH((items ? 1 : 0) + 1 + notes.length) + picH(r.id),
|
||||
});
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
@@ -616,6 +622,9 @@ export function layoutMilestone(
|
||||
});
|
||||
}
|
||||
|
||||
// 254-3 장소 그림 = 글 아래 · 안쪽 여백 안(그림 없으면 자리 없음)
|
||||
for (const b of boxes)
|
||||
if (pics.has(b.id)) b.pic = { x: b.x + INSET, y: b.y + b.h - INSET - PIC.h, ...PIC };
|
||||
const lines = pending.map(([from, to, legs]) =>
|
||||
edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!),
|
||||
);
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 ·
|
||||
GET 답 `place_images` {상자 id: "place/<id>.png"} · `place_times` {상자 id: 바뀐 시각 ms}
|
||||
(캐시 깨기).
|
||||
경로 그림(256-2) = [저장] 뒤 같은 길로 경로 탭과 같은 전체 경로(거래처 → 기준점 사슬 → 시점 ·
|
||||
구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · 표지 = `mapMarks` 차례) → `auto/<경로 id>.png`
|
||||
· 지문(표지 · 수기) 바뀐 경로만 · 화면 [길찾기] 그림(거래처 구간만)이 들어오면 지문을 지워 다음
|
||||
[저장]에 전체로.
|
||||
공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례
|
||||
(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례).
|
||||
표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝).
|
||||
@@ -62,7 +66,13 @@ from B01_Dashboard.B01_Dashboard_Map import reverse_geocode
|
||||
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
|
||||
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
|
||||
from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import SITE, compute_milestone, migrate_milestone, r3
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import (
|
||||
SITE,
|
||||
base_chain,
|
||||
compute_milestone,
|
||||
migrate_milestone,
|
||||
r3,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
@@ -320,6 +330,36 @@ def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: flo
|
||||
return got
|
||||
|
||||
|
||||
def route_chain(data: dict[str, Any], route: dict[str, Any], start: list[float] | None) -> list:
|
||||
"""경로 그림 사슬 — [(이름, 경도, 위도, 수기)] · 화면 `mapMarks` 와 같은 차례 ·
|
||||
좌표 없는 상자 뺌."""
|
||||
owners = [(route.get("name") or "거래처", route)]
|
||||
if route.get("linked", True) is not False:
|
||||
owners += [
|
||||
(b.get("name") or "기준점", b) for b in base_chain(data, route.get("to") or SITE)
|
||||
]
|
||||
out = [(n, o.get("lon"), o.get("lat"), bool(o.get("manual"))) for n, o in owners]
|
||||
if start:
|
||||
out.append(("시점", start[0], start[1], False))
|
||||
return [c for c in out if c[1] is not None and c[2] is not None]
|
||||
|
||||
|
||||
def route_image(root: str | Path, route_id: str, chain: list) -> dict[str, Any]:
|
||||
"""경로 그림 한 장 — 구간마다 카카오 길 선(수기 · 실패 = 곧은 선) → 캡처 → 지문 적음."""
|
||||
lines = []
|
||||
for (_, lon1, lat1, manual), (_, lon2, lat2, _) in zip(chain, chain[1:]):
|
||||
path = []
|
||||
if not manual:
|
||||
got = directions_km(LonLat(lon=lon1, lat=lat1), LonLat(lon=lon2, lat=lat2))
|
||||
path = got.get("path") or []
|
||||
lines.append(path or [[lat1, lon1], [lat2, lon2]])
|
||||
marks = [{"name": n, "lon": lon, "lat": lat} for n, lon, lat, _ in chain]
|
||||
got = make_route_image(folder(root) / AUTO, route_id, lines, marks)
|
||||
if got["saved"]:
|
||||
_set_print(root, f"{AUTO}/{route_id}", _print(chain))
|
||||
return got
|
||||
|
||||
|
||||
def refresh_images(root: str | Path) -> None:
|
||||
"""[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 ·
|
||||
실패 = 다음 [저장]에 다시."""
|
||||
@@ -329,6 +369,15 @@ def refresh_images(root: str | Path) -> None:
|
||||
for place_id, name, lon, lat in places(data):
|
||||
if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have:
|
||||
place_image(root, place_id, name, lon, lat)
|
||||
start = site_info(root, None)["start_lonlat"]
|
||||
have = images(root, AUTO)
|
||||
for route in data["routes"]:
|
||||
chain = route_chain(data, route, start)
|
||||
key = f"{AUTO}/{route['id']}"
|
||||
if route.get("linked", True) is False or len(chain) < 2:
|
||||
continue # 안 이은 거래처 = 경로 그림 없음
|
||||
if prints.get(key) != _print(chain) or route["id"] not in have:
|
||||
route_image(root, route["id"], chain)
|
||||
|
||||
|
||||
def _route_id(route: str) -> str:
|
||||
@@ -359,7 +408,10 @@ async def milestone_save(
|
||||
) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
out = await asyncio.to_thread(save_data, root, body)
|
||||
tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임
|
||||
if not os.environ.get(
|
||||
"PYTEST_CURRENT_TEST"
|
||||
): # 남의 시험 [저장] = 실제 카카오 · 브라우저 안 부름
|
||||
tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임
|
||||
return out
|
||||
|
||||
|
||||
@@ -470,6 +522,8 @@ async def milestone_image_auto(
|
||||
lines = [[list(p) for p in line] for line in body.lines]
|
||||
marks = [m.model_dump() for m in body.marks]
|
||||
got = await asyncio.to_thread(make_route_image, folder(root) / AUTO, route_id, lines, marks)
|
||||
if got["saved"]: # 거래처 구간만 그림 — 다음 [저장]에 전체 경로로 다시(256-2)
|
||||
await asyncio.to_thread(_set_print, root, f"{AUTO}/{route_id}", None)
|
||||
return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]}
|
||||
|
||||
|
||||
|
||||
@@ -183,6 +183,11 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 254-3 상자 안 장소 그림 — 누름 · 끌기는 상자 몫 */
|
||||
.b08-milestone__pic {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 · 높이 어디서나 한 값(150) */
|
||||
.b08-milestone__map {
|
||||
width: 100%;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) ·
|
||||
* 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km)
|
||||
* 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소)
|
||||
* 장소 그림(254-3) = 서버 `place/<상자 id>.png`(256-1) 을 상자 글 아래 작게 · 그림 없으면 자리 없음
|
||||
* 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점
|
||||
* 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소
|
||||
* ========================================================================== */
|
||||
@@ -42,6 +43,8 @@ import { openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milesto
|
||||
import {
|
||||
findSegmentKm,
|
||||
liveMilestone,
|
||||
placeImageBlobUrl,
|
||||
placeImageKey,
|
||||
putMilestone,
|
||||
roadOptions,
|
||||
siteAddress,
|
||||
@@ -298,6 +301,18 @@ export function createDiagram(
|
||||
addElem.dataset.b08MilestoneAdd = "open";
|
||||
const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] });
|
||||
|
||||
/** 장소 그림(254-3 · 256-1) blob 주소 — 상자마다 키(이름#시각)가 바뀔 때만 다시 받음 */
|
||||
const picUrls = new Map<string, { key: string; url: Promise<string> }>();
|
||||
const picUrl = (id: string, key: string): Promise<string> => {
|
||||
let got = picUrls.get(id);
|
||||
if (!got || got.key !== key) {
|
||||
got?.url.then(URL.revokeObjectURL, () => undefined);
|
||||
got = { key, url: placeImageBlobUrl(projectId, id) };
|
||||
picUrls.set(id, got);
|
||||
}
|
||||
return got.url;
|
||||
};
|
||||
|
||||
const refresh = (): void => {
|
||||
const data = liveMilestone(projectId);
|
||||
const site = siteInfo();
|
||||
@@ -313,6 +328,7 @@ export function createDiagram(
|
||||
mid_address: addr?.mid_address,
|
||||
},
|
||||
mk,
|
||||
new Set([...data.bases, ...data.routes].map((o) => o.id).filter((id) => placeImageKey(id))),
|
||||
);
|
||||
// 틀 = 페이지 폭 전체 · 높이 고정(CSS · 149) — 그림 범위를 틀 비율로 넓혀 가운데 둠
|
||||
// (화면 px ↔ 그림 좌표 비율이 가로세로 같아야 팬 · 줌이 어긋나지 않음)
|
||||
@@ -361,9 +377,10 @@ export function createDiagram(
|
||||
...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]),
|
||||
];
|
||||
const x = box.x + box.w / 2;
|
||||
const th = box.pic ? box.pic.y - box.y : box.h; // 글 자리 = 그림 위
|
||||
const text = svg("text", {
|
||||
x,
|
||||
y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2,
|
||||
y: box.kind === "site" ? box.y + 18 : box.y + th / 2 - ((rows.length - 1) * 15) / 2,
|
||||
"text-anchor": "middle",
|
||||
"dominant-baseline": "central",
|
||||
});
|
||||
@@ -371,6 +388,24 @@ export function createDiagram(
|
||||
text.append(svg("tspan", { x, dy: i ? 15 : 0, ...(cls ? { class: cls } : {}) }, row)),
|
||||
);
|
||||
g.append(svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), text);
|
||||
// 254-3 장소 그림 — 글 아래 작게(그림 없으면 자리 없음 · 못 읽으면 지움)
|
||||
const key = box.pic && placeImageKey(box.id);
|
||||
if (box.pic && key) {
|
||||
const { x: px, y: py, w: pw, h: ph } = box.pic;
|
||||
const pic = svg("image", {
|
||||
class: "b08-milestone__pic",
|
||||
x: px,
|
||||
y: py,
|
||||
width: pw,
|
||||
height: ph,
|
||||
});
|
||||
pic.setAttribute("preserveAspectRatio", "xMidYMid slice");
|
||||
g.append(pic);
|
||||
picUrl(box.id, key).then(
|
||||
(url) => pic.setAttribute("href", url),
|
||||
() => pic.remove(),
|
||||
);
|
||||
}
|
||||
if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소
|
||||
// 상자 누름 = 모달(탭 이동 아님)
|
||||
const info = (): void => INFO[box.kind](box.id);
|
||||
|
||||
@@ -104,7 +104,7 @@ export function openPlaceModal(
|
||||
? "요소 추가 — 기준점 · 거래처"
|
||||
: base
|
||||
? `기준점 — ${base.name || "기준점"}`
|
||||
: `항목 정보 — ${route!.name || "거래처"}`;
|
||||
: `거래처 — ${route!.name || "거래처"}`; // 254-3 표 머리 글(항목 정보) 없음
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: "닫기",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# 2026-10-11 이관 — 253 ~ 260 (요구 162 묶음)
|
||||
|
||||
## 253. 거리이정표 → 설계내역서 운반공 · 운반 단가 모달 (sub2 · 요구 162-1)
|
||||
정함(브레인) = `docs/raw/요구_1010_2/조사_AJ_162묶음.md` 1장 · 사용자 확정 = 행 하나 = 운반공 줄 하나 · 연결 · 미연결 같은 모달
|
||||
- [x] 253-1 운반공 줄 = 거리이정표 행 1:1(자동 상자 옛 키 · 사용자 상자 `haul:r-{id}`) · 서버 B09 가 경로 전부 읽음 — 시험 · 검증완료(test_253_1 3건 · 사용자 상자 둘째 행 `haul:r-{id}.1` · 중점 행 L2 2.8)
|
||||
- [x] 253-2 사이클시간 짝(Py · TS · 건설품셈 8-2-8 · 거창 단가산출 1308~1347 재현) + 로직 입력 이름 채움(Cm · 사이클시간 · t2 · 운반거리) · 서버 B09 셈 때 거리로 다시 채움 — 시험 · 검증완료(test_253_2 5건 · 거창 칸 읽어 t1 5.33 · t2 101.23 · Cm 109.78 · TS 거울 4벌 같음 · L1 10 이면 Cm 45.69)
|
||||
- [x] 253-3 미리보기 길 `POST …/estimation/logic-preview`(render_sangeun_text) + 「운반 단가」 모달(단가산출 · [변경] · 옵션 값 · 계산 절차) — 거리이정표 우클릭 · 설계내역서 운반공 줄에서 엶 — 시험 · typecheck · 검증완료(test_253_3 4건 · GF000134 Cm 109.78 → 16,290원/㎥ · typecheck 새 빨강 없음)
|
||||
|
||||
## 254. 거리이정표 여러 기준점 · 다이어그램 (sub8 · 요구 162-4 · 5 · 6 · 8 · 10)
|
||||
정함(브레인) = 조사 AJ 2장 · 사용자 확정 = 기준점 2개 이상 · 기준점에도 선 연결
|
||||
- [x] 254-1 데이터 `bases[]` · 경로 `to` · 옛 `base` 옮김 · 돌기 막음 · 도로 구분(시점에 닿는 기준점 구간 1차 · 그 밖 2차) · Calc 짝 · 서버 모델 — 시험(먼저 커밋 · 보고)
|
||||
- 검증완료 — test_254_1 5(옛 꼴 옮김 Py · TS · 서버 읽기 · PUT 새 꼴 · 기준점 둘 사슬 거래처 28.3 · 0.6 · 0.3 = 29.2 거울 · 안 이음 멈춤 · 돌기 A↔B 끊음 · makesLoop) + 244_* · 250_1 · 251_1 포함 84 통과 · typecheck · ruff
|
||||
- [x] 254-2 다이어그램 — 기준점 상자 연결 점 · 기준점 주소 · 연결 점 왼쪽 위 안쪽 · 항목 글 = 거래처 글 · 주소 줄바꿈 + 상자 높이 · 선 글 두 줄(도로 / km) · 선 길이 거리 비례(로그 · 최소 · 최대) — 시험
|
||||
- 검증완료 — test_254_2 4(선 길이 = 100 + 90·log10(1 + km) · 최소 100 · 최대 320 · 테두리 사이 실측 · 주소 2줄 = 높이 73 · 3줄 넘으면 줄임표 · 선 글 두 줄 높이 34 · 연결 점 (10, 10) r 6 · 돌기 취소) + 244_* · 250_1 · 251_1 · 254_1 포함 88 통과 · typecheck · prettier
|
||||
- [x] 254-3 상자 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」 · 장소 그림(`place_images`) 상자 안 배치(256-1 꼴) — 시험 · typecheck
|
||||
- 검증완료 — test_254_3 2(그림 132 × 88 · 글 아래 안쪽 여백 8 · 상자 높이 + 96 · 그림 없는 상자 높이 그대로 · 제목 「거래처 —」 · 키 바뀔 때만 다시 받음) + 244_* · 250_1 · 251_1 · 254_* 포함 90 통과 · typecheck · prettier
|
||||
|
||||
## 255. 거리이정표 시점 모달 · 거래처/기준점 모달 · 경로 탭 (sub1 · 요구 162-2 · 3 · 4)
|
||||
정함(브레인) = 조사 AJ 2장
|
||||
- [x] 255-1 시점 모달 — 중점 측점 km · 설명 줄 지움 · 지도 = 공용 카카오 지도(시점 · 중점 표지) — 시험 · 검증완료(test_255_1 · 1.2345 km → 1234.5 m · 빈칸 = null · 244-4 · 244-6 기대값 고침 12 통과)
|
||||
- [x] 255-2 (254-1 합친 뒤) 경로 탭 — 시점도 끌기(`site.start_pin` · 길찾기용) · 기준점 옮기면 그 기준점에 닿는 구간 모두 다시 · 다시 열면 모든 구간 선 · 실패 안내 — 시험 · 검증완료(test_255_2 · 덮개 없음 → 127,35 · 덮개 → 128.25,36.5 · 기준점 b1 = b1 · b2 · r2 다시(안 이은 r4 빠짐) · 거리이정표 시험 86 통과)
|
||||
- [x] 255-3 (254-1 합친 뒤) 거래처 · 기준점 모달 — 제목 ≥ 내용 글 · 「소재지 — 주소 찾기 · 지도에서 고르기(…)」 지움 · 기준점 이름 한 줄 · 주소 · 지도 위 가로선 — typecheck · 검증완료(typecheck 0 · test_255_3 · 칸 이름 12 → 14px(입력 글과 같음) · 장소 그림 capturePlace = 등록 · 좌표 바뀜 · 경로 탭 끌기 · 거리이정표 시험 94 통과)
|
||||
|
||||
## 256. 거리이정표 그림 영구 저장 (sub4 · 요구 162-6 · 7)
|
||||
정함(브레인) = 조사 AJ 2장 · 사용자 확정 = 그림은 뒤에 다른 곳에서 씀 · 잘 보관 · 경로 그림은 화면 기능 변경 아님
|
||||
- [x] 256-1 장소 그림 — 거래처 · 기준점 좌표가 정해지면 서버가 작은 지도 캡처 → `milestone/place/<id>.png` · GET 에 `place_images` · 지운 상자 그림은 [저장] 때 지움 — 시험
|
||||
- 검증완료(test_256_1 6건 · 실제 캡처 검증_sub4 place 2장 480×320 · 약 0.13MB)
|
||||
- [x] 256-2 경로 그림 — 경로 탭과 같은 전체 경로(기준점 사슬 포함 · 254-1 뒤) → `milestone/auto/<경로 id>.png` · 좌표 · 구간이 바뀐 경로는 [저장] 때 다시 캡처 — 시험
|
||||
- 검증완료(test_256_2 4건 · 실제 캡처 auto/r1.png 1200×800 · 0.97MB · 도로 구간 길 선 · 길찾기 실패 구간 곧은 선 · 다시 [저장] 0.02초)
|
||||
|
||||
## 257. B09 탭 · 표 (sub3 · 요구 162-11 · 12 · 13 · 15 · 16 · 17 · 18)
|
||||
정함(브레인) = 조사 AJ 3장
|
||||
- [x] 257-1 위 탭 「설계내역」(총괄설계내역서 · 설계내역서) · 단가연결 칸 = 램프(누름 그대로 · 풍선) · 머리 「단가↵연결」 · 폭 줄임 — 시험
|
||||
- 검증완료(test_257_1 둘 · test_181_1 · test_229_1 · B09 UI 시험 167 통과 · 위 탭 8 · 램프 칸 class 셋 · 머리 폭 44)
|
||||
- [x] 257-2 이동해 온 줄 = 모든 탭 빨강 상자 · 비고 「빈 줄 N」 지움(Py · TS 짝) · 총공사비 = 아래 고정 줄(총괄 · 원가계산서) — 시험
|
||||
- 검증완료(test_257_2 셋 · test_185_1 · test_145_3 · 145_4 · 182 · 242_1 · 200_2 · B09 시험 248 통과 · 145_5 1건은 경비 집계표 안내 글 = 이 절 밖 · typecheck 새 오류 0)
|
||||
- [x] 257-3 자재단가대비표 적용 단가 = 초록 테두리 · 우클릭 「적용 단가 바꾸기」 — 시험 · typecheck
|
||||
- 검증완료(test_257_3 둘 · test_183_3 · B09 시험 248 통과 · typecheck B09 오류 0 — B08 Milestone_Modal 1건은 이 절 밖)
|
||||
|
||||
## 258. 표 양식 · 구조물 조합 색 (sub5 · 요구 162-9 · 14 · 22)
|
||||
정함(브레인) = 조사 AJ 4장 · 사용자 확정 = 입력 칸 테두리 옅게 되살림
|
||||
- [x] 258-1 템플릿 표 폭 = CSS 변수 · 거리이정표 표 100% — 시험 · 검증완료(test_258_1 · test_ui_data_table 통과 · 인라인 --dt-w · css width var · 고정 열 표 폭 값 그대로)
|
||||
- [x] 258-2 입력 칸 테두리(템플릿 · outline · 옅게) · 시험 셋 기대값 고침 — 시험 · 검증완료(accent 28% outline · test_243_1 · 246_1 · 241_1 통과)
|
||||
- [x] 258-3 B05/B06 구조물 조합 미지정 = 회색(구조물리스트 램프 색) — typecheck · 검증완료(text-muted 테두리 · 12% 바탕 · test_177_1 기대값 고침 통과)
|
||||
|
||||
## 259. B06 횡단 상세 — 영역 · 길이 · 표 강조 (sub7 · 요구 162-19 · 20)
|
||||
정함(브레인) = 조사 AJ 5장
|
||||
- [x] 259-1 공용 팬 `moved` 고침 · 암 경계선 나눔 · 고름(서버 · 스키마 다섯 곳) — 시험 · 검증완료(test_259 5건 · 팬 뒤 누름 통과 · 암 경계선 셋으로 나뉨 · 가운데 5.33m 서버 거울 · 209·164·179·150·244 47건)
|
||||
- [x] 259-2 닫힌 영역 안 누름 고름 · 채움 · 면적 · 둘레 / 선 길이 · 기울기 · 표 칸 누름 = 그래프 고름 + 같은 정보 — 시험 · 검증완료(test_259 11건 · 면 둘 6.00㎡ · 4.67㎡ 찾음 · 열린 영역 0 · 붙은 둘 = 10.67㎡ 서버 거울 · 표 칸 → 저장 조각 · 띠 짝)
|
||||
- [x] 259-3 표 강조 바깥 한 줄 · 겹선 없음 · 다른 묶음 안 물듦 — typecheck · 검증완료(ㄴ 모양 바깥 줄 10토막 · 칸 사이 겹선 없음 · tr:has 물들임 없앰 · typecheck 내 파일 0건)
|
||||
|
||||
## 260. B06 상세 → B07 횡단배수 CAD 칸 (sub6 · 요구 162-21)
|
||||
정함(브레인) = 조사 AJ 5장 · 사용자 확정 = 빈 CAD(바탕만) · 구조물 없으면 빈값 · 횡단배수만 미리 넣음 · B07 표 위
|
||||
- [x] 260-1 새 칸 `structure_note_cad`(스키마 · 남김 · 새로 셈 · 저장 patch) · 옛 글 옮김 — 시험 · 검증완료(test_260_note_cad 칸 4건 · test_138 7건 통과 · typecheck 내 파일 0)
|
||||
- [x] 260-2 상세 모달 글 상자 → 빈 CAD 도면(cad_host · 높이 고정 · 닫으면 destroy) · 횡단배수 미리 넣음 스크립트(주인 측점 · 비었을 때만) — 시험 · 검증완료(test_260_note_cad 미리 넣음 10건 — 그림 7줄 일치 · 빈 구조물 · 세월교 · 숨은 관 · 이웃 = 빈값)
|
||||
- [x] 260-3 B07 = CAD 칸 엔티티를 표 위(좌상 맞춤 · 범위 크기) · 없으면 옛 글 줄 — 시험 · 검증완료(test_260_note_cad B07 5건 — 범위 왼쪽 = 표 왼쪽 · 위 = 옛 표 윗변 · 표 15 mm 내림 = 범위 높이 · 배율 2 = 30 mm · test_150 · 174 · 180 통과)
|
||||
- [x] 260-4 안 연 측점 = B07 이 파이썬 짝 스크립트로 넣음(TS 와 거울) · 지운 것([]) 유지 — 시험 · 검증완료(test_260_note_cad 거울 9쌍 TS = 파이썬 · 칸 없음만 넣음 · [] · 옛 글 · 이웃 그대로 · B07 표 내림 = 범위 높이 · B07 시험 108 통과)
|
||||
@@ -1,6 +1,6 @@
|
||||
/* PLAN 260 단위검증 헬퍼 — [상세] 표 위 CAD 칸 · 옛 글 옮김 · 횡단배수 미리 넣음.
|
||||
* `B06_Section_UI_Cross_Detail_Cad.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다(CAD · 세션 · 서버 = 흉내).
|
||||
* 결과는 표준출력에 JSON 한 벌. 확장자 `.cjs` 까닭은 `helper_108_drag_box_hit.cjs` 머리와 같다.
|
||||
* 결과는 표준출력에 JSON 한 벌 · 인자 `mirror` = 표준입력 입력으로 거울 결과(260-4). 확장자 `.cjs` 까닭은 `helper_108_drag_box_hit.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -108,4 +108,13 @@ const out = {
|
||||
cad.stationNoLabel(1439.97, 20),
|
||||
],
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
// 거울(260-4) — 시험이 표준입력으로 준 같은 입력 [{section, station, options, chainage, interval}]
|
||||
// → TS 엔티티 · 측점 이름(파이썬 짝 `_Engine_Cad_CulvertNote` 와 대조)
|
||||
if (process.argv[2] === "mirror") {
|
||||
const cases = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const mirrored = cases.map((item) => ({
|
||||
entities: cad.culvertNoteEntities(item.section, item.station, item.options),
|
||||
station: cad.stationNoLabel(item.chainage, item.interval),
|
||||
}));
|
||||
process.stdout.write(JSON.stringify(mirrored));
|
||||
} else process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -59,9 +59,10 @@ console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hi
|
||||
"""
|
||||
)
|
||||
src = IMPORTS.sub("", _read(LAYOUT)) + script
|
||||
# 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", src],
|
||||
"--input-type=module-typescript", "-"], input=src,
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -27,9 +27,10 @@ def _node(script):
|
||||
IMPORTS.sub("", _read(rel))
|
||||
for rel in ("A00_Common/b_svg_zoom_pan.ts", "B08_Quantity/B08_Quantity_Milestone_Layout.ts")
|
||||
)
|
||||
# 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", src + "\n" + script],
|
||||
"--input-type=module-typescript", "-"], input=src + "\n" + script,
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""254-3 B08 거리이정표 상자 모달 제목 · 장소 그림(사용자 162-6 · 8).
|
||||
|
||||
① 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 「항목 정보」 없음)
|
||||
② 장소 그림(256-1 `place_images`) = 상자 안 글 아래 작게 · 그림 없으면 자리 없음(상자 높이 그대로)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
B08 = "B08_Quantity/B08_Quantity_"
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _node(expr):
|
||||
src = IMPORTS.sub("", _read(B08 + "Milestone_Layout.ts"))
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-"],
|
||||
input=src + f"\nconsole.log(JSON.stringify({expr}));",
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
DATA = {
|
||||
"site": {"mid_station_m": None, "mid_road": "비포장"},
|
||||
"bases": [{"id": "B", "name": "병곡리", "address": "거창읍", "dir": None, "to": "site",
|
||||
"legs": [{"road": None, "km": 0.6}], "linked": True}],
|
||||
"routes": [
|
||||
{"id": "g", "name": "거창군청", "address": "거창읍", "dir": "N", "to": "B",
|
||||
"legs": [{"road": None, "km": 23.8}],
|
||||
"rows": [{"item": "장비", "dest": "start", "note": ""}]},
|
||||
{"id": "k", "name": "김천역", "address": "", "dir": "E", "to": "site",
|
||||
"legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]},
|
||||
],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_장소_그림_상자_안_글_아래():
|
||||
d = json.dumps(DATA, ensure_ascii=False)
|
||||
plain, with_pic = _node(
|
||||
f"[layoutMilestone({d}, null, 0.5), layoutMilestone({d}, null, 0.5, new Set(['B', 'g']))]"
|
||||
)
|
||||
a = {b["id"]: b for b in plain["boxes"]}
|
||||
b = {b["id"]: b for b in with_pic["boxes"]}
|
||||
pic = _node("PIC")
|
||||
assert pic == {"w": 132, "h": 88} # 서버 480 × 320 과 같은 3:2 · 상자 안쪽 폭
|
||||
# 그림 없음 = 자리 없음(높이 그대로 · pic 없음)
|
||||
assert all("pic" not in box for box in a.values())
|
||||
assert "pic" not in b["k"] and b["k"]["h"] == a["k"]["h"]
|
||||
for bid in ("B", "g"):
|
||||
box, before = b[bid], a[bid]
|
||||
assert box["h"] == before["h"] + pic["h"] + 8 # 그림 + 아래 여백만큼 늘림
|
||||
p = box["pic"]
|
||||
# 안쪽 여백 안 · 글 아래(글 자리 = 그림 위 높이 = 그림 없을 때 높이)
|
||||
assert p["x"] == box["x"] + 8 and p["x"] + p["w"] == box["x"] + box["w"] - 8
|
||||
assert p["y"] - box["y"] == before["h"] and p["y"] + p["h"] == box["y"] + box["h"] - 8
|
||||
|
||||
|
||||
def test_모달_제목_화면_그림():
|
||||
modal = _read(B08 + "UI_Milestone_Modal.ts")
|
||||
assert "`거래처 — ${route!.name" in modal and "`기준점 — ${base.name" in modal
|
||||
assert "항목 정보 —" not in modal
|
||||
ui = _read(B08 + "UI_Milestone_Diagram.ts")
|
||||
assert "placeImageBlobUrl(projectId, id)" in ui and "placeImageKey(id)" in ui
|
||||
assert 'pic.setAttribute("href", url)' in ui and "() => pic.remove()" in ui
|
||||
assert "if (!got || got.key !== key)" in ui # 키(이름#시각) 바뀔 때만 다시 받음
|
||||
assert "const th = box.pic ? box.pic.y - box.y : box.h;" in ui # 글 = 그림 위 가운데
|
||||
css = _read(B08 + "UI_Milestone.css")
|
||||
pic = css[css.index(".b08-milestone__pic {") :]
|
||||
assert "pointer-events: none;" in pic[: pic.index("}")] # 누름 · 끌기 = 상자 몫
|
||||
for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_Modal.ts"):
|
||||
assert len(_read(B08 + name).splitlines()) <= 700, name
|
||||
@@ -55,6 +55,7 @@ def _save(root, data):
|
||||
def _fake(monkeypatch):
|
||||
fake = Fake()
|
||||
monkeypatch.setattr(ms, "make_place_image", fake)
|
||||
monkeypatch.setattr(ms, "route_image", lambda *a: {"saved": False}) # 경로 그림 = 256-2 시험
|
||||
return fake
|
||||
|
||||
|
||||
@@ -121,6 +122,7 @@ def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ms, "_project_root", root)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST") # [저장] 뒤 캡처 켬(시험 중엔 꺼 둠)
|
||||
app = FastAPI()
|
||||
app.include_router(ms.router)
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""256-2 B08 거리이정표 경로 그림 = 전체 경로(사용자 162-7).
|
||||
|
||||
캡처 · 길찾기는 가짜로:
|
||||
① 사슬 = 거래처 → 기준점 사슬(254 bases · to) → 시점 · 화면 `mapMarks` 차례 · 좌표 없는 상자 뺌
|
||||
② 구간마다 카카오 길 선 · 수기 · 실패 구간 = 곧은 선 · auto/<경로 id>.png
|
||||
③ [저장] 뒤 지문 비교 — 안 바뀜 = 0 · 기준점 · 시점 옮김 = 그 사슬 경로만 · 안 이은 거래처 = 없음 ·
|
||||
화면 [길찾기] 그림(거래처 구간만)이 들어오면 다음 [저장]에 전체로
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\nfake-route"
|
||||
START = [127.5, 35.4]
|
||||
BASES = [
|
||||
{"id": "b1", "name": "거창IC", "lon": 127.91, "lat": 35.68, "to": "b2"},
|
||||
{"id": "b2", "name": "함양JC", "lon": 127.72, "lat": 35.52, "manual": True},
|
||||
]
|
||||
ROUTES = [
|
||||
{"id": "r1", "name": "거창레미콘", "lon": 127.95, "lat": 35.70, "to": "b1"},
|
||||
{"id": "r2", "name": "직송", "lon": 127.60, "lat": 35.45},
|
||||
{"id": "r3", "name": "안 이음", "lon": 127.61, "lat": 35.46, "linked": False},
|
||||
{"id": "r4", "name": "좌표 없음", "to": "b2"},
|
||||
]
|
||||
DATA = {"bases": BASES, "routes": ROUTES}
|
||||
|
||||
|
||||
class Fake:
|
||||
def __init__(self):
|
||||
self.shots = [] # (경로 id, lines, marks)
|
||||
self.asked = [] # 길찾기 구간
|
||||
|
||||
def shoot(self, out_dir, route_id, lines, marks):
|
||||
self.shots.append((route_id, lines, marks))
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / f"{route_id}.png").write_bytes(PNG)
|
||||
return {"saved": True, "reason": None}
|
||||
|
||||
def directions(self, origin, destination):
|
||||
self.asked.append((origin.lon, destination.lon))
|
||||
if origin.lon == 127.60: # r2 → 시점 = 길찾기 실패
|
||||
return {"km": None, "reason": "실패"}
|
||||
return {"km": 1.0, "reason": None, "path": [[origin.lat, origin.lon], [0, 0]]}
|
||||
|
||||
|
||||
def _setup(tmp_path, monkeypatch, start=START):
|
||||
fake = Fake()
|
||||
box = {"start": start}
|
||||
monkeypatch.setattr(ms, "make_route_image", fake.shoot)
|
||||
monkeypatch.setattr(ms, "directions_km", fake.directions)
|
||||
monkeypatch.setattr(ms, "make_place_image", lambda *a: {"saved": False}) # 256-1 시험
|
||||
monkeypatch.setattr(
|
||||
ms, "site_info", lambda root, mid: {"start_lonlat": box["start"], "mid_default_m": None}
|
||||
)
|
||||
return fake, box
|
||||
|
||||
|
||||
def _save(root, data):
|
||||
ms.save_data(root, ms.Milestone.model_validate(data))
|
||||
ms.refresh_images(root)
|
||||
|
||||
|
||||
def test_사슬_화면_차례():
|
||||
data = ms.Milestone.model_validate(DATA).model_dump()
|
||||
chain = ms.route_chain(data, data["routes"][0], START)
|
||||
assert chain == [
|
||||
("거창레미콘", 127.95, 35.70, False),
|
||||
("거창IC", 127.91, 35.68, False),
|
||||
("함양JC", 127.72, 35.52, True),
|
||||
("시점", 127.5, 35.4, False),
|
||||
]
|
||||
assert [c[0] for c in ms.route_chain(data, data["routes"][3], START)] == ["함양JC", "시점"]
|
||||
assert [c[0] for c in ms.route_chain(data, data["routes"][1], None)] == ["직송"]
|
||||
|
||||
|
||||
def test_전체_경로_그림_구간_선(tmp_path, monkeypatch):
|
||||
fake, _ = _setup(tmp_path, monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
shots = {rid: (lines, marks) for rid, lines, marks in fake.shots}
|
||||
assert sorted(shots) == ["r1", "r2", "r4"] # r3 안 이음 = 없음
|
||||
lines, marks = shots["r1"]
|
||||
assert [m["name"] for m in marks] == ["거창레미콘", "거창IC", "함양JC", "시점"]
|
||||
assert len(lines) == 3
|
||||
assert lines[0] == [[35.70, 127.95], [0, 0]] # 카카오 길 선
|
||||
assert lines[2] == [[35.52, 127.72], [35.4, 127.5]] # 수기 기준점 = 곧은 선 · 길찾기 안 함
|
||||
assert (127.72, 127.5) not in fake.asked
|
||||
assert shots["r2"][0] == [[[35.45, 127.60], [35.4, 127.5]]] # 길찾기 실패 = 곧은 선
|
||||
assert (tmp_path / "B08_Quantity/milestone/auto/r1.png").read_bytes() == PNG
|
||||
|
||||
|
||||
def test_바뀐_경로만_다시(tmp_path, monkeypatch):
|
||||
fake, box = _setup(tmp_path, monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
fake.shots.clear()
|
||||
ms.refresh_images(tmp_path)
|
||||
assert fake.shots == [] # 안 바뀜
|
||||
moved = {**DATA, "bases": [BASES[0], {**BASES[1], "lat": 35.55}]}
|
||||
_save(tmp_path, moved)
|
||||
assert sorted(s[0] for s in fake.shots) == ["r1", "r4"] # b2 를 지나는 경로만
|
||||
fake.shots.clear()
|
||||
box["start"] = [127.4, 35.3] # 시점 옮김(노선 수정) = 모두
|
||||
ms.refresh_images(tmp_path)
|
||||
assert sorted(s[0] for s in fake.shots) == ["r1", "r2", "r4"]
|
||||
fake.shots.clear()
|
||||
(tmp_path / "B08_Quantity/milestone/auto/r2.png").unlink() # 그림 없음 = 다시
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [s[0] for s in fake.shots] == ["r2"]
|
||||
|
||||
|
||||
def test_길찾기_그림_뒤엔_다음_저장에_전체로(tmp_path, monkeypatch):
|
||||
fake, _ = _setup(tmp_path, monkeypatch)
|
||||
|
||||
async def root(project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ms, "_project_root", root)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST") # [저장] 뒤 캡처 켬(시험 중엔 꺼 둠)
|
||||
app = FastAPI()
|
||||
app.include_router(ms.router)
|
||||
client = TestClient(app)
|
||||
base = f"/api/projects/{uuid4()}/quantity/milestone"
|
||||
assert client.put(base, json=DATA).status_code == 200 # 답 뒤 캡처
|
||||
assert sorted(s[0] for s in fake.shots) == ["r1", "r2", "r4"]
|
||||
fake.shots.clear()
|
||||
part = {"lines": [[[35.70, 127.95], [35.68, 127.91]]], "marks": []}
|
||||
assert client.post(base + "/image/auto", params={"route": "r1"}, json=part).status_code == 200
|
||||
assert [s[0] for s in fake.shots] == ["r1"] # 화면 길찾기 그림(거래처 구간만)
|
||||
fake.shots.clear()
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [(s[0], len(s[2])) for s in fake.shots] == [("r1", 4)] # 전체 경로로 다시
|
||||
@@ -294,3 +294,160 @@ def test_CAD_칸이_없으면_옛_글_줄_그대로():
|
||||
noted = _draw({"structure_note": "L형 측구\n집수정 보강"})
|
||||
labels = [e["shapeData"]["label"] for e in noted["entities"] if e["type"] == "Text"]
|
||||
assert labels == ["L형 측구", "집수정 보강"]
|
||||
|
||||
|
||||
# ── 260-4 안 연 측점 = B07 이 파이썬 짝으로 넣음 ─────────────────────────
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402
|
||||
culvert_note_entities,
|
||||
load_pipe_notes,
|
||||
station_no_label,
|
||||
with_culvert_note,
|
||||
)
|
||||
|
||||
_OPTIONS = {
|
||||
"pipe_diameter_mm": 1200,
|
||||
"inlet_type": "기슭막이",
|
||||
"inlet_revet_form": "돌쌓기(찰)",
|
||||
"inlet_revet_height_m": 2,
|
||||
"inlet_revet_length_m": 10,
|
||||
"outlet_type": "기슭막이",
|
||||
"outlet_revet_form": "돌쌓기(메)",
|
||||
"outlet_revet_height_m": 2,
|
||||
"outlet_revet_length_m": 10,
|
||||
"chute_add_1_structure": "돌붙임",
|
||||
"chute_add_1_bond": "찰붙임",
|
||||
"chute_add_1_area": 10,
|
||||
}
|
||||
_NO_CHUTE = {k: v for k, v in _OPTIONS.items() if not k.startswith("chute")}
|
||||
|
||||
|
||||
def _case(options: dict, pipe_at: float, chainage: float, design: dict | None, **spec) -> dict:
|
||||
culvert = {**_culvert_set(options), "chainage_m": pipe_at, **spec}
|
||||
return {
|
||||
"section": {"chainage_m": chainage, "culvert": culvert, "design": design},
|
||||
"station": station_no_label(chainage, 20.0),
|
||||
"options": options,
|
||||
"chainage": chainage,
|
||||
"interval": 20.0,
|
||||
}
|
||||
|
||||
|
||||
_MIRROR_CASES = [
|
||||
_case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}),
|
||||
_case(_NO_CHUTE, 1433.0, 1433.0, {}),
|
||||
_case(
|
||||
{
|
||||
**_NO_CHUTE,
|
||||
"inlet_type": "집수정",
|
||||
"inlet_structure": "L",
|
||||
"inlet_basin_height_m": 1.7,
|
||||
"inlet_basin_length_m": 2,
|
||||
},
|
||||
1433.0,
|
||||
1433.0,
|
||||
{"pipe_length_m": 9.5},
|
||||
),
|
||||
_case({**_NO_CHUTE, "chute_add_2_structure": "도수로", "chute_add_2_area": ""}, 80, 80, None),
|
||||
_case({**_OPTIONS, "pipe_diameter_mm": 800, "chute_add_1_area": 12.345}, 12.5, 12.5, {}),
|
||||
_case({**_OPTIONS, "inlet_revet_form": "콘크리트"}, 440.241, 440.0, {"pipe_length_m": 7}),
|
||||
_case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}),
|
||||
_case(_OPTIONS, 1433.0, 1433.0, {}, body="ford"),
|
||||
_case(_OPTIONS, 1433.0, 1433.0, {}, hidden_pipe=True),
|
||||
]
|
||||
|
||||
|
||||
def _without_id(entities: list[dict]) -> list[dict]:
|
||||
return [{k: v for k, v in entity.items() if k != "id"} for entity in entities]
|
||||
|
||||
|
||||
def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티():
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"],
|
||||
input=json.dumps(_MIRROR_CASES, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
mirrored = json.loads(proc.stdout)
|
||||
assert len(mirrored) == len(_MIRROR_CASES)
|
||||
for case, ts in zip(_MIRROR_CASES, mirrored, strict=True):
|
||||
section = case["section"]
|
||||
py = culvert_note_entities(
|
||||
section["chainage_m"],
|
||||
section["culvert"],
|
||||
section["design"],
|
||||
case["station"],
|
||||
case["options"],
|
||||
)
|
||||
assert _without_id(py) == _without_id(ts["entities"]), case["station"]
|
||||
assert station_no_label(case["chainage"], case["interval"]) == ts["station"]
|
||||
# 대조가 빈 목록끼리만이 아님 — 주인 측점 여섯은 그림이 있음 · 이웃 · 세월교 · 숨은 관 = 빈값
|
||||
assert [bool(item["entities"]) for item in mirrored] == [True] * 6 + [False] * 3
|
||||
labels = [e["shapeData"].get("label") for e in mirrored[4]["entities"]]
|
||||
assert "Φ800m/m" in labels and "관유출부보강(돌붙임,찰) 12.35m2" in labels
|
||||
|
||||
|
||||
def _write_pipes(root: Path, points: list[dict]) -> None:
|
||||
path = pipe_points_file(root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def test_정본_배수관만_읽음(tmp_path):
|
||||
_write_pipes(
|
||||
tmp_path,
|
||||
[
|
||||
{"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS},
|
||||
{"chainage_m": 80.0, "lonlat": [0, 0], "facility": "pipe", "options": {}},
|
||||
{"chainage_m": 90.0, "lonlat": [0, 0], "facility": "ford_bridge", "options": {}},
|
||||
],
|
||||
)
|
||||
pipes = load_pipe_notes(tmp_path)
|
||||
assert [pipe[0] for pipe in pipes] == [1433.0] # 관경 안 고름 · 세월교 = 빼
|
||||
assert pipes[0][1]["chainage_m"] == 1433.0 and pipes[0][2] == _OPTIONS
|
||||
assert load_pipe_notes(tmp_path / "없음") == []
|
||||
|
||||
|
||||
def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_그대로(tmp_path):
|
||||
_write_pipes(
|
||||
tmp_path,
|
||||
[{"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}],
|
||||
)
|
||||
pipes = load_pipe_notes(tmp_path)
|
||||
design = {"pipe_length_m": 22, "paved": False}
|
||||
seeded = with_culvert_note(design, 1433.0, pipes, "NO.71+13")
|
||||
assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜
|
||||
labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]]
|
||||
assert labels[2:] == [
|
||||
"배수관",
|
||||
"Φ1200m/m",
|
||||
"L=22.0m",
|
||||
"NO.71+13",
|
||||
"관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m",
|
||||
"관보호공(유출) 기슭막이(메,H=2.0) L=10.0m",
|
||||
"관유출부보강(돌붙임,찰) 10m2",
|
||||
]
|
||||
cleared = {**design, "structure_note_cad": {"entities": []}}
|
||||
assert with_culvert_note(cleared, 1433.0, pipes, "x") is cleared
|
||||
old = {**design, "structure_note": "옛 글"}
|
||||
assert with_culvert_note(old, 1433.0, pipes, "x") is old
|
||||
assert with_culvert_note(None, 1433.0, pipes, "x") is None
|
||||
assert with_culvert_note(design, 1440.0, pipes, "x") is design
|
||||
|
||||
|
||||
def test_B07_은_안_연_측점에_미리_넣음을_표_위에_그림(tmp_path):
|
||||
_write_pipes(
|
||||
tmp_path,
|
||||
[{"chainage_m": 20.2, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}],
|
||||
)
|
||||
design = with_culvert_note({"pipe_length_m": 22}, 20.0, load_pipe_notes(tmp_path), "NO.1")
|
||||
drawn = _draw(design)
|
||||
labels = [e["shapeData"]["label"] for e in drawn["entities"] if e["type"] == "Text"]
|
||||
assert "NO.1" in labels and "관유출부보강(돌붙임,찰) 10m2" in labels
|
||||
plain_top = _table(_draw({"pipe_length_m": 22}))["shapeData"]["origin"]["y"]
|
||||
drop = plain_top - _table(drawn)["shapeData"]["origin"]["y"]
|
||||
assert drop == pytest.approx(cross_note_block_height(design)) and drop > 15
|
||||
|
||||
Reference in New Issue
Block a user