Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_6', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -28,7 +28,7 @@ import type { LinkOptions, Links } from "./spreadsheet_links";
|
||||
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
import { attachMouse } from "./spreadsheet_mouse";
|
||||
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
|
||||
import { LAST_C, LAST_R, selectCell, selectRanges } from "./spreadsheet_selection";
|
||||
import { createStatusBar } from "./spreadsheet_statusbar";
|
||||
import { mountTabs } from "./spreadsheet_tabs";
|
||||
import { mountToolbar } from "./spreadsheet_toolbar";
|
||||
@@ -90,6 +90,9 @@ export interface SpreadsheetHandle {
|
||||
/** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) ·
|
||||
* `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */
|
||||
pasteTable(html: string, check?: PasteCheck): Promise<string | null>;
|
||||
/** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) ·
|
||||
* 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */
|
||||
select(범위: string | string[], 시트?: string): boolean;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -350,6 +353,18 @@ export function createSpreadsheet(
|
||||
return 이름;
|
||||
},
|
||||
pasteTable: (html, check) => pasteTableAt(ctx, html, check),
|
||||
select(range, sheetKey) {
|
||||
const target = sheetKey
|
||||
? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey)
|
||||
: ctx.sheet();
|
||||
const next = target && selectRanges(target, typeof range === "string" ? [range] : range);
|
||||
if (!target || !next) return false;
|
||||
showSheet(target.id);
|
||||
if (ctx.selection.시트 !== target.id) return false; // 편집기 유효성에 막힘
|
||||
select(next, true);
|
||||
grid.reveal(next.활성.r, next.활성.c); // 범위가 판보다 크면 왼위가 보이게
|
||||
return true;
|
||||
},
|
||||
destroy() {
|
||||
clearTimeout(toastTimer);
|
||||
detachKeys();
|
||||
|
||||
@@ -91,6 +91,20 @@ export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress):
|
||||
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
|
||||
}
|
||||
|
||||
/** A1 범위 글 여럿 → 고름(329-2 손잡이 `select`) — 병합만큼 늘림 · 활성 = 첫 범위 왼위 · 틀린 글 · 빈 목록 = null */
|
||||
export function selectRanges(sheet: Sheet, texts: string[]): Selection | null {
|
||||
const list = merges(sheet);
|
||||
const 범위: CellRange[] = [];
|
||||
for (const t of texts) {
|
||||
const g = parseRange(t);
|
||||
if (!g) return null;
|
||||
범위.push(expand(g, list));
|
||||
}
|
||||
if (!범위.length) return null;
|
||||
const at = { r: 범위[0].r0, c: 범위[0].c0 };
|
||||
return { 시트: sheet.id, 범위, 활성: at, 기준: { ...at } };
|
||||
}
|
||||
|
||||
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
|
||||
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
|
||||
const r0 = Math.min(a, b);
|
||||
|
||||
@@ -37,12 +37,14 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
|
||||
COST_LENGTH_M,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
||||
CROSSING_BAND,
|
||||
OBJECTIVES,
|
||||
NoRoute,
|
||||
RouteGraph,
|
||||
)
|
||||
from common_util.common_util_route_geometry import densify_route
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
|
||||
ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
|
||||
@@ -70,6 +72,8 @@ MIN_RANGE_SHARE = 0.05
|
||||
SIMILAR_M = 10.0
|
||||
# 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기
|
||||
CONSTRAINT_SHARE = 1.0
|
||||
# 탐색 경로를 예정노선처럼 폴리라인화하기 전 점 간격(m) — R 맞춤이 꺾임점 사이 점으로 잼
|
||||
TRACE_DENSIFY_M = 2.0
|
||||
|
||||
Measure = Callable[..., dict[str, Any]]
|
||||
Route = tuple[Any, dict[str, Any]]
|
||||
@@ -103,14 +107,27 @@ def _trace(
|
||||
traced: dict[tuple, Route],
|
||||
**kw: Any,
|
||||
) -> Route:
|
||||
# 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선.
|
||||
# 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 1단 판(탐색 경로를
|
||||
# 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 · 옛 규칙)
|
||||
# 보다 많지 않은 첫 판. 셋 다 화면이 [확인] 때 같은 선으로 다시 짜는 꼴.
|
||||
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
|
||||
key = (tuple(nodes), tuple(sorted(kw.items())))
|
||||
if key not in traced:
|
||||
outline = build_planned_polyline(
|
||||
floor = build_planned_polyline(
|
||||
nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes)
|
||||
)
|
||||
traced[key] = (outline, measure(outline, **kw))
|
||||
least = (floor, measure(floor, **kw))
|
||||
path = densify_route(nodes, TRACE_DENSIFY_M)
|
||||
fitted = build_planned_polyline(path, min_radius_m=min_radius_m)
|
||||
larger = (widen(fitted, path, min_radius_m), fitted)
|
||||
traced[key] = next(
|
||||
(
|
||||
route
|
||||
for route in ((outline, measure(outline, **kw)) for outline in larger)
|
||||
if route[1]["over_limit"]["total"] <= least[1]["over_limit"]["total"]
|
||||
),
|
||||
least,
|
||||
)
|
||||
return traced[key]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""추천 노선 큰 R — 2단(PLAN 328-2 · 요구 224-4 「R 은 클수록 좋음」).
|
||||
|
||||
`B05_Profile_Engine_RouteInitial_Pool._trace` 가 부름. 예상노선 추적(전처리 초기값)은 안 거침.
|
||||
|
||||
1단(`_trace`)이 예정노선 규칙(단순화 · 같은 방향 묶음 · R 맞춤)으로 짠 선을 받아, 곡선마다 R 을
|
||||
[맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로 점과 가장 먼 거리 ≤ `ROUTE_RECOMMEND_MAX_GAP_M`
|
||||
인 가장 큰 값(1 m 내림)으로 고른다. 맞는 값이 없으면 맞춘 R 그대로.
|
||||
|
||||
고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로
|
||||
펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from shapely.geometry import LineString, Point
|
||||
|
||||
from common_util.common_util_route_polyline import (
|
||||
PlannedPolyline,
|
||||
_inner_angle_deg,
|
||||
build_planned_polyline,
|
||||
)
|
||||
from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M
|
||||
|
||||
Xy = tuple[float, float]
|
||||
|
||||
|
||||
def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | None]]:
|
||||
"""곡선 묶음을 교각점 하나로 편 노드 · 곡선 켬 · R — 화면 `flattenServerPlan` 과 같은 꼴."""
|
||||
starts = {curve.node_first: curve for curve in outline.curves}
|
||||
inside = {i for c in outline.curves for i in range(c.node_first + 1, c.node_last + 1)}
|
||||
points: list[Xy] = []
|
||||
flags: list[bool] = []
|
||||
radii: list[float | None] = []
|
||||
for index, node in enumerate(outline.nodes):
|
||||
if index in inside:
|
||||
continue
|
||||
curve = starts.get(index)
|
||||
points.append(curve.apex if curve else (node.x, node.y))
|
||||
flags.append(curve is not None)
|
||||
radii.append(round(curve.radius_m, 2) if curve else None)
|
||||
return points, flags, radii
|
||||
|
||||
|
||||
def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray:
|
||||
return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0]
|
||||
|
||||
|
||||
def _gap(samples: np.ndarray, before: Xy, apex: Xy, after: Xy, radii: np.ndarray) -> np.ndarray:
|
||||
"""R 마다 점들이 「직선 · 원호 · 직선」(앞뒤 직선은 절반까지)에서 가장 먼 거리(m)."""
|
||||
a = np.asarray(apex, dtype=float)
|
||||
p = samples - a # (S, 2) 교각점 기준
|
||||
units = [(np.asarray(end, dtype=float) - a) for end in (before, after)]
|
||||
halves = [float(np.hypot(*u)) / 2 for u in units]
|
||||
units = [u / (2 * h) for u, h in zip(units, halves)]
|
||||
inner = math.radians(_inner_angle_deg(before, apex, after))
|
||||
tangent = radii[:, None] * math.tan((math.pi - inner) / 2) # (M, 1)
|
||||
legs = []
|
||||
for unit, half in zip(units, halves):
|
||||
along = p @ unit
|
||||
side = np.abs(_cross(unit, p))
|
||||
legs.append(np.hypot(side, np.maximum(0.0, np.maximum(tangent - along, along - half))))
|
||||
bisector = (units[0] + units[1]) / np.hypot(*(units[0] + units[1]))
|
||||
center = (radii / math.sin(inner / 2))[:, None, None] * bisector # (M, 1, 2)
|
||||
starts = [tangent[..., None] * unit - center for unit in units] # 중심 → 접선점
|
||||
q = p[None] - center # (M, S, 2) 중심 → 점
|
||||
turn = _cross(starts[0], starts[1])
|
||||
within = (_cross(starts[0], q) * turn >= 0) & (_cross(q, starts[1]) * turn >= 0)
|
||||
arc = np.where(within, np.abs(np.hypot(q[..., 0], q[..., 1]) - radii[:, None]), np.inf)
|
||||
return np.minimum(np.minimum(legs[0], legs[1]), arc).max(axis=1)
|
||||
|
||||
|
||||
def widen(
|
||||
outline: PlannedPolyline,
|
||||
path: list[Xy],
|
||||
min_radius_m: float,
|
||||
max_gap_m: float = ROUTE_RECOMMEND_MAX_GAP_M,
|
||||
) -> PlannedPolyline:
|
||||
"""곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로."""
|
||||
if not outline.curves:
|
||||
return outline
|
||||
points, flags, radii = _flat(outline)
|
||||
line = LineString(path)
|
||||
coords = np.asarray(path, dtype=float)
|
||||
along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))])
|
||||
for index in range(1, len(points) - 1):
|
||||
if not flags[index]:
|
||||
continue
|
||||
before, apex, after = points[index - 1], points[index], points[index + 1]
|
||||
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2)
|
||||
if half_tan <= 1e-9:
|
||||
continue
|
||||
room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan
|
||||
candidates = np.arange(math.floor(room), float(radii[index]), -1.0) # 큰 것부터
|
||||
# 이 곡선이 쓰는 자리 = 앞뒤 직선 가운데점 사이 — 그 사이 탐색 경로 점만 잼
|
||||
ends = [
|
||||
line.project(Point((apex[0] + end[0]) / 2, (apex[1] + end[1]) / 2))
|
||||
for end in (before, after)
|
||||
]
|
||||
samples = coords[(along >= min(ends)) & (along <= max(ends))]
|
||||
if not candidates.size or not len(samples):
|
||||
continue
|
||||
ok = candidates[_gap(samples, before, apex, after, candidates) <= max_gap_m]
|
||||
if ok.size:
|
||||
radii[index] = float(ok[0])
|
||||
return build_planned_polyline(
|
||||
points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii
|
||||
)
|
||||
@@ -79,21 +79,31 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
|
||||
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고,
|
||||
받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다.
|
||||
"""
|
||||
curves = _read_design_curve_table(project_root).get("curves")
|
||||
return (
|
||||
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
|
||||
)
|
||||
|
||||
|
||||
def load_station_snap(project_root: Path) -> bool:
|
||||
"""[측점 스냅] 켬 — 곡선표 `"station_snap"`(요구 224-2 · [확인] 때 적힘). 칸 없으면 끔."""
|
||||
return _read_design_curve_table(project_root).get("station_snap") is True
|
||||
|
||||
|
||||
def _read_design_curve_table(project_root: Path) -> dict[str, Any]:
|
||||
"""설계가 쓰는 곡선표 파일 통째 — 수정본이 있으면 그 곁 것 · 없거나 못 읽으면 빈 dict."""
|
||||
route_path = planned_route_working_path(project_root)
|
||||
if not route_path.is_file():
|
||||
route_path = planned_route_initial_path(project_root)
|
||||
target = route_path.with_name(f"{route_path.stem}_curves.json")
|
||||
if not target.is_file():
|
||||
return []
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
|
||||
return []
|
||||
curves = data.get("curves") if isinstance(data, dict) else None
|
||||
return (
|
||||
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
|
||||
)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
# ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ────────────
|
||||
@@ -435,6 +445,7 @@ def run_section_generation(
|
||||
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
|
||||
# 종전의 측점별 실측 반경으로 물러선다.
|
||||
design_curves=_load_design_curves(project_root),
|
||||
station_snap=load_station_snap(project_root),
|
||||
)
|
||||
|
||||
stage_root = project_root / _STAGE_SUBDIR
|
||||
@@ -548,6 +559,8 @@ def sample_structure_sections(
|
||||
crs=crs,
|
||||
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
|
||||
design_curves=_load_design_curves(project_root),
|
||||
# 규칙 측점과 같은 스냅 — 한쪽만 켜면 [저장] 때 옮기기 전 격자(640)가 되살아남.
|
||||
station_snap=load_station_snap(project_root),
|
||||
)
|
||||
|
||||
def structural(entry: dict[str, Any]) -> bool:
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
|
||||
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
|
||||
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔
|
||||
`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험
|
||||
`resources/tester/test_224_2_station_snap.py`.
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -275,6 +278,35 @@ def _design_curve_spans(
|
||||
return sorted(spans)
|
||||
|
||||
|
||||
#: 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」 — 관 · 구조물 겹침 판정과 같은 값(m).
|
||||
CURVE_STATION_EDGE_M = 0.005
|
||||
|
||||
|
||||
def snap_curve_stations(
|
||||
stations: np.ndarray | list[float],
|
||||
spans: list[tuple[float, ...]],
|
||||
) -> np.ndarray:
|
||||
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 정수 MC
|
||||
(floor(MC+0.5))로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 측점과 같은 m 이면 하나로.
|
||||
|
||||
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리).
|
||||
"""
|
||||
values = [float(value) for value in stations]
|
||||
for span in spans:
|
||||
start, end = float(span[0]), float(span[1])
|
||||
middle = (start + end) / 2.0
|
||||
inside = [
|
||||
index
|
||||
for index, value in enumerate(values)
|
||||
if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M
|
||||
]
|
||||
if not inside:
|
||||
continue
|
||||
nearest = min(inside, key=lambda index: abs(values[index] - middle))
|
||||
values[nearest] = float(math.floor(middle + 0.5))
|
||||
return np.unique(np.asarray(values, dtype=np.float64))
|
||||
|
||||
|
||||
def _design_curve_widenings(
|
||||
station_chainage: np.ndarray,
|
||||
spans: list[tuple[float, float, float, str]],
|
||||
@@ -319,8 +351,12 @@ def generate_sections(
|
||||
source_snapshot: dict[str, Any] | None = None,
|
||||
crs: str | None = None,
|
||||
design_curves: list[dict[str, Any]] | None = None,
|
||||
station_snap: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
|
||||
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
|
||||
|
||||
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2).
|
||||
"""
|
||||
options = options or SectionGenerationOptions()
|
||||
options.validate()
|
||||
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
|
||||
@@ -331,6 +367,9 @@ def generate_sections(
|
||||
long_z, long_valid = sampler.sample_xy(long_xy)
|
||||
|
||||
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
|
||||
spans = _design_curve_spans(points, route_chainage, design_curves or [])
|
||||
if station_snap:
|
||||
station_chainage = snap_curve_stations(station_chainage, spans)
|
||||
# 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다.
|
||||
regular_rounded = {round(float(value), 6) for value in station_chainage}
|
||||
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
|
||||
@@ -372,7 +411,6 @@ def generate_sections(
|
||||
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
|
||||
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
|
||||
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
|
||||
spans = _design_curve_spans(points, route_chainage, design_curves or [])
|
||||
if spans:
|
||||
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
|
||||
station_chainage, spans
|
||||
|
||||
@@ -41,10 +41,17 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
|
||||
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||
RouteInitialCriteria,
|
||||
RouteInitialRequest,
|
||||
RouteInitialWeights,
|
||||
RouteReplanRequest,
|
||||
RouteVertexInput,
|
||||
)
|
||||
from common_util.common_util_initial_snapshot import design_route_csv_path
|
||||
from common_util.common_util_route_geometry import (
|
||||
densify_route,
|
||||
@@ -69,74 +76,6 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
||||
_PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}
|
||||
|
||||
|
||||
class RouteVertexInput(BaseModel):
|
||||
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
|
||||
|
||||
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
|
||||
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
|
||||
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
|
||||
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
|
||||
"""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
curve: bool = True
|
||||
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
|
||||
radius_m: float | None = None
|
||||
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
|
||||
|
||||
|
||||
class RouteInitialWeights(BaseModel):
|
||||
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
|
||||
|
||||
expected: float = Field(50.0, ge=0, le=100)
|
||||
earthwork: float = Field(25.0, ge=0, le=100)
|
||||
grade: float = Field(25.0, ge=0, le=100)
|
||||
|
||||
|
||||
class RouteInitialCriteria(BaseModel):
|
||||
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
|
||||
|
||||
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
|
||||
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
|
||||
"""
|
||||
|
||||
max_grade_pct: float | None = Field(None, ge=0, le=100)
|
||||
min_radius_m: float | None = Field(None, ge=0)
|
||||
stream_offset_m: float | None = Field(None, ge=0)
|
||||
grade_smooth_m: float | None = Field(None, ge=0)
|
||||
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
|
||||
target_grade_pct: float | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class RouteInitialRequest(BaseModel):
|
||||
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
|
||||
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
|
||||
|
||||
mode: Literal[
|
||||
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
|
||||
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
|
||||
weights: RouteInitialWeights | None = None
|
||||
criteria: RouteInitialCriteria | None = None
|
||||
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
|
||||
with_profile: bool = True
|
||||
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
|
||||
terrain: Literal["las", "sheet"] | None = None
|
||||
|
||||
|
||||
class RouteReplanRequest(BaseModel):
|
||||
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
|
||||
|
||||
vertices: list[RouteVertexInput] = Field(default_factory=list)
|
||||
initial: RouteInitialRequest | None = None
|
||||
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
|
||||
balanced_profile: bool = False
|
||||
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
|
||||
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
|
||||
terrain: Literal["las", "sheet"] | None = None
|
||||
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
|
||||
|
||||
|
||||
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
|
||||
"""(프로젝트 실경로, 저장소 상대경로). 없으면 None."""
|
||||
pool = get_db_pool()
|
||||
@@ -305,9 +244,12 @@ def _write_planned_polyline(
|
||||
simplify: bool = True,
|
||||
curve_flags: list[bool] | None = None,
|
||||
radii: list[float | None] | None = None,
|
||||
station_snap: bool | None = None,
|
||||
) -> dict:
|
||||
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
|
||||
|
||||
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔).
|
||||
|
||||
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
||||
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
||||
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
|
||||
@@ -321,13 +263,13 @@ def _write_planned_polyline(
|
||||
)
|
||||
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
|
||||
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
|
||||
_curves_path(path).write_text(
|
||||
json.dumps(
|
||||
{"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
table: dict[str, Any] = {
|
||||
"min_radius_m": round(radius_m, 3),
|
||||
"curves": [c.as_dict() for c in result.curves],
|
||||
}
|
||||
if station_snap is not None:
|
||||
table["station_snap"] = bool(station_snap)
|
||||
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
|
||||
return {
|
||||
"nodes": len(result.nodes),
|
||||
"curves": result.curve_count,
|
||||
@@ -572,6 +514,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
|
||||
"violation_count": outline.violation_count,
|
||||
"edited": bool(working),
|
||||
"station_snap": await asyncio.to_thread(load_station_snap, project_root),
|
||||
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
|
||||
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
|
||||
}
|
||||
@@ -604,11 +547,17 @@ async def replan_route(
|
||||
radii = [vertex.radius_m for vertex in request.vertices]
|
||||
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
|
||||
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
|
||||
# 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음.
|
||||
working_path = planned_route_working_path(project_root)
|
||||
previous = working_path.read_bytes() if working_path.is_file() else None
|
||||
keep = (working_path, _curves_path(working_path), _nodes_path(working_path))
|
||||
previous = {path: path.read_bytes() if path.is_file() else None for path in keep}
|
||||
summary = await asyncio.to_thread(
|
||||
functools.partial(
|
||||
_write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii
|
||||
_write_planned_polyline,
|
||||
simplify=False,
|
||||
curve_flags=curve_flags,
|
||||
radii=radii,
|
||||
station_snap=request.station_snap,
|
||||
),
|
||||
working_path,
|
||||
nodes,
|
||||
@@ -633,10 +582,11 @@ async def replan_route(
|
||||
)
|
||||
if "error" in result:
|
||||
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
|
||||
if previous is None:
|
||||
working_path.unlink(missing_ok=True)
|
||||
else:
|
||||
working_path.write_bytes(previous)
|
||||
for path, saved in previous.items():
|
||||
if saved is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
path.write_bytes(saved)
|
||||
logger.warning(
|
||||
"계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s",
|
||||
project_id,
|
||||
|
||||
@@ -133,6 +133,8 @@ class CrossPreviewRequest(BaseModel):
|
||||
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
|
||||
cross_half_width_m: float | None = Field(None, gt=0)
|
||||
terrain: AnalysisTerrain | None = None
|
||||
#: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로.
|
||||
station_snap: bool = False
|
||||
|
||||
|
||||
def _cross_preview(
|
||||
@@ -177,7 +179,14 @@ def _cross_preview(
|
||||
else defaults.station_interval_m,
|
||||
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
|
||||
)
|
||||
result = generate_sections(built.vertices, sampler, options)
|
||||
# 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙.
|
||||
result = generate_sections(
|
||||
built.vertices,
|
||||
sampler,
|
||||
options,
|
||||
design_curves=[curve.as_dict() for curve in built.curves],
|
||||
station_snap=request.station_snap,
|
||||
)
|
||||
sections = result["cross_sections"]
|
||||
if not sections:
|
||||
return None
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod
|
||||
from config.config_system import (
|
||||
@@ -309,3 +309,76 @@ class RouteLatestResponse(BaseModel):
|
||||
route_points: list[dict[str, Any]] = Field(default_factory=list)
|
||||
surface_params: dict[str, Any]
|
||||
route_params: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ── 계획노선 편집 요청(`B05_Profile_Router_Replan`) ─────────────────────────────
|
||||
|
||||
|
||||
class RouteVertexInput(BaseModel):
|
||||
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
|
||||
|
||||
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
|
||||
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
|
||||
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
|
||||
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
|
||||
"""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
curve: bool = True
|
||||
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
|
||||
radius_m: float | None = None
|
||||
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
|
||||
|
||||
|
||||
class RouteInitialWeights(BaseModel):
|
||||
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
|
||||
|
||||
expected: float = Field(50.0, ge=0, le=100)
|
||||
earthwork: float = Field(25.0, ge=0, le=100)
|
||||
grade: float = Field(25.0, ge=0, le=100)
|
||||
|
||||
|
||||
class RouteInitialCriteria(BaseModel):
|
||||
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
|
||||
|
||||
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
|
||||
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
|
||||
"""
|
||||
|
||||
max_grade_pct: float | None = Field(None, ge=0, le=100)
|
||||
min_radius_m: float | None = Field(None, ge=0)
|
||||
stream_offset_m: float | None = Field(None, ge=0)
|
||||
grade_smooth_m: float | None = Field(None, ge=0)
|
||||
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
|
||||
target_grade_pct: float | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class RouteInitialRequest(BaseModel):
|
||||
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
|
||||
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
|
||||
|
||||
mode: Literal[
|
||||
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
|
||||
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
|
||||
weights: RouteInitialWeights | None = None
|
||||
criteria: RouteInitialCriteria | None = None
|
||||
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
|
||||
with_profile: bool = True
|
||||
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
|
||||
terrain: Literal["las", "sheet"] | None = None
|
||||
|
||||
|
||||
class RouteReplanRequest(BaseModel):
|
||||
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
|
||||
|
||||
vertices: list[RouteVertexInput] = Field(default_factory=list)
|
||||
initial: RouteInitialRequest | None = None
|
||||
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
|
||||
balanced_profile: bool = False
|
||||
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
|
||||
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
|
||||
terrain: Literal["las", "sheet"] | None = None
|
||||
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
|
||||
station_snap: bool = False
|
||||
"""[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음."""
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` 의 `_interpolate_xy`·`_tangent_at`
|
||||
* (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고
|
||||
* 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림.
|
||||
*
|
||||
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations`
|
||||
* ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` ·
|
||||
* `snap_curve_stations`. 거울 시험 `resources/tester/test_224_2_station_snap.py`.
|
||||
* ========================================================================== */
|
||||
|
||||
type Vertex = [number, number];
|
||||
@@ -75,3 +79,98 @@ export function stationFrameAt(
|
||||
const tangent: Vertex = [(to[0] - from[0]) / length, (to[1] - from[1]) / length];
|
||||
return { origin: interpolate(points, chainage, chainageM), left: [-tangent[1], tangent[0]] };
|
||||
}
|
||||
|
||||
/** 곡선표 시 · 종점이 노선에서 이보다 멀면 그 노선 곡선이 아님(m) — 파이썬 같은 이름. */
|
||||
export const DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0;
|
||||
/** 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」(m) — 파이썬 같은 이름. */
|
||||
export const CURVE_STATION_EDGE_M = 0.005;
|
||||
|
||||
/** 곡선표 한 줄에서 스냅이 읽는 칸 — 서버 곡선표 · `EditedCurve` 모두 이 꼴을 품음. */
|
||||
export interface CurveEnds {
|
||||
start?: ReadonlyArray<number> | null;
|
||||
apex?: ReadonlyArray<number> | null;
|
||||
end?: ReadonlyArray<number> | null;
|
||||
radius_m?: number | null;
|
||||
}
|
||||
|
||||
/** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */
|
||||
export function stationsAlong(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
intervalM: number,
|
||||
includeEndpoint: boolean,
|
||||
): number[] {
|
||||
const { chainage } = cleanPolyline(line);
|
||||
const total = chainage.length ? chainage[chainage.length - 1] : 0;
|
||||
const values: number[] = [];
|
||||
if (intervalM > 0)
|
||||
for (let i = 0; i * intervalM < total + 1e-9; i += 1) values.push(i * intervalM);
|
||||
if (!values.length || Math.abs(values[0]) > 1e-9) values.unshift(0);
|
||||
if (includeEndpoint && total - values[values.length - 1] > 1e-6) values.push(total);
|
||||
return values;
|
||||
}
|
||||
|
||||
/** 점을 노선 위로 내려 [떨어진 거리, 누가거리] — `_project_chainage` 짝(같으면 앞 선분). */
|
||||
function projectChainage(
|
||||
points: Vertex[],
|
||||
chainage: number[],
|
||||
xy: ReadonlyArray<number>,
|
||||
): [number, number] {
|
||||
let bestGap = Infinity;
|
||||
let bestAt = 0;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const [sx, sy] = points[index];
|
||||
const vx = points[index + 1][0] - sx;
|
||||
const vy = points[index + 1][1] - sy;
|
||||
const length2 = vx * vx + vy * vy;
|
||||
const safe = length2 > 1e-12 ? length2 : 1;
|
||||
const ratio = Math.min(1, Math.max(0, ((xy[0] - sx) * vx + (xy[1] - sy) * vy) / safe));
|
||||
const gap = Math.hypot(sx + vx * ratio - xy[0], sy + vy * ratio - xy[1]);
|
||||
if (gap < bestGap) {
|
||||
bestGap = gap;
|
||||
bestAt = chainage[index] + (chainage[index + 1] - chainage[index]) * ratio;
|
||||
}
|
||||
}
|
||||
return [bestGap, bestAt];
|
||||
}
|
||||
|
||||
/** 곡선표 → [BC, EC] 누가거리(앞쪽부터) — 노선 밖 곡선 · 방향 못 재는 곡선은 버림.
|
||||
* `_design_curve_spans` 짝(반지름 · 바깥쪽 칸은 스냅이 안 써 뺌). */
|
||||
export function curveSpans(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<CurveEnds>,
|
||||
): Array<[number, number]> {
|
||||
const { points, chainage } = cleanPolyline(line);
|
||||
if (points.length < 2) return [];
|
||||
const spans: Array<[number, number]> = [];
|
||||
for (const { start, apex, end, radius_m } of curves) {
|
||||
if (!start?.length || !apex?.length || !end?.length || typeof radius_m !== "number") continue;
|
||||
const [startGap, startAt] = projectChainage(points, chainage, start);
|
||||
const [endGap, endAt] = projectChainage(points, chainage, end);
|
||||
if (Math.max(startGap, endGap) > DESIGN_CURVE_MATCH_TOLERANCE_M) continue;
|
||||
const cross =
|
||||
(apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (end[0] - apex[0]);
|
||||
if (Math.abs(cross) < 1e-12) continue;
|
||||
spans.push([Math.min(startAt, endAt), Math.max(startAt, endAt)]);
|
||||
}
|
||||
return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
}
|
||||
|
||||
/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC+0.5) 로 옮김 ·
|
||||
* 측점 없는 호는 그대로 · 같은 m 는 하나로(오름차순). `snap_curve_stations` 짝. */
|
||||
export function snapCurveStations(
|
||||
stations: ReadonlyArray<number>,
|
||||
spans: ReadonlyArray<readonly [number, number]>,
|
||||
): number[] {
|
||||
const values = [...stations];
|
||||
for (const [start, end] of spans) {
|
||||
const middle = (start + end) / 2;
|
||||
let nearest = -1;
|
||||
values.forEach((value, index) => {
|
||||
if (value <= start + CURVE_STATION_EDGE_M || value >= end - CURVE_STATION_EDGE_M) return;
|
||||
if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle))
|
||||
nearest = index;
|
||||
});
|
||||
if (nearest >= 0) values[nearest] = Math.floor(middle + 0.5);
|
||||
}
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
|
||||
follow: {
|
||||
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
|
||||
how: [
|
||||
"새로 길을 찾지 않음 — 예상노선 꺾임점에 최소 곡선반지름으로 곡선만 넣음",
|
||||
"새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음",
|
||||
"전처리 초기 계획노선과 같은 규칙이라 같은 선이 나옴",
|
||||
"종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음",
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import { Actor } from 'xstate';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { setAngleLock } from '../inputController/input-angle-lock';
|
||||
import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state';
|
||||
import type { CadCommand } from './command.types';
|
||||
import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry';
|
||||
@@ -38,6 +39,7 @@ export function runCommand(command: CadCommand): string {
|
||||
}
|
||||
|
||||
lastCommandId = command.id;
|
||||
setAngleLock(null); // 앞 명령의 각도 고정은 새 명령에 넘기지 않음
|
||||
|
||||
if (command.machine) {
|
||||
setActiveToolActor(new Actor(command.machine));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import { getAngleLock, pointAtLock, projectOnLock } from '../inputController/input-angle-lock.ts';
|
||||
import {
|
||||
type AbsolutePointInputEvent,
|
||||
ActorEvent,
|
||||
@@ -12,21 +13,26 @@ import {
|
||||
/**
|
||||
* Various tools need to convert user input into a point
|
||||
* This function handles mouse click event, number events and in the future absoluteCoordinates and relativeCoordinate events
|
||||
* 각도 고정(`<30`) 중이면 커서 · 클릭 · 숫자를 기준점에서 고정 방향으로.
|
||||
* @param startPoint
|
||||
* @param event
|
||||
*/
|
||||
export function getPointFromEvent(startPoint: Point | null, event: PointInputEvent): Point {
|
||||
const locked = startPoint !== null && getAngleLock() !== null;
|
||||
if (event.type === ActorEvent.DRAW) {
|
||||
return (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
const mouse = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
return locked ? projectOnLock(startPoint, mouse) : mouse;
|
||||
}
|
||||
if (event.type === ActorEvent.MOUSE_CLICK) {
|
||||
return (event as MouseClickEvent).worldMouseLocation;
|
||||
const click = (event as MouseClickEvent).worldMouseLocation;
|
||||
return locked ? projectOnLock(startPoint, click) : click;
|
||||
}
|
||||
if (event.type === ActorEvent.NUMBER_INPUT) {
|
||||
if (!startPoint) {
|
||||
throw new Error('Cannot get relative point by distance if no start point is provided');
|
||||
}
|
||||
const distance = (event as NumberInputEvent).value;
|
||||
if (locked) return pointAtLock(startPoint, distance);
|
||||
// Direction indicated by the startPoint and the mouse location
|
||||
const direction = new Vector(
|
||||
event.worldMouseLocation.x - startPoint.x,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 각도 고정 `<각도` — 오토캐드 각도 재지정. 다음 한 점만 기준점에서 그 방향(동쪽 0° 반시계 +).
|
||||
* 커서 · 클릭은 고정 방향 직선 위로 내리고 · 숫자는 그 방향 길이. 점 하나 쓰면 · Esc · 새 명령이면 풂.
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { polarToPoint } from './input-patterns.ts';
|
||||
|
||||
let lockedDegrees: number | null = null;
|
||||
|
||||
export const getAngleLock = (): number | null => lockedDegrees;
|
||||
|
||||
export const setAngleLock = (degrees: number | null) => {
|
||||
lockedDegrees = degrees;
|
||||
};
|
||||
|
||||
/** 커서 옆 안내 줄 */
|
||||
export const angleLockHint = (): string | null =>
|
||||
lockedDegrees === null ? null : `각도 ${lockedDegrees}° 고정 — 길이 입력`;
|
||||
|
||||
/** 기준점에서 고정 방향으로 거리만큼 간 점 */
|
||||
export function pointAtLock(base: Point, distance: number): Point {
|
||||
const offset = polarToPoint(distance, lockedDegrees ?? 0);
|
||||
return new Point(base.x + offset.x, base.y + offset.y);
|
||||
}
|
||||
|
||||
/** 점을 기준점 · 고정 방향 직선 위로 내림(커서 · 클릭) */
|
||||
export function projectOnLock(base: Point, point: Point): Point {
|
||||
const unit = polarToPoint(1, lockedDegrees ?? 0);
|
||||
const along = (point.x - base.x) * unit.x + (point.y - base.y) * unit.y;
|
||||
return new Point(base.x + unit.x * along, base.y + unit.y * along);
|
||||
}
|
||||
@@ -44,14 +44,7 @@ import {
|
||||
} from '../state.ts';
|
||||
import { Tool } from '../tools.ts';
|
||||
import { SelectState, selectToolStateMachine } from '../tools/select-tool.ts';
|
||||
import {
|
||||
type AbsolutePointInputEvent,
|
||||
ActorEvent,
|
||||
type MouseClickEvent,
|
||||
type NumberInputEvent,
|
||||
type RelativePointInputEvent,
|
||||
type TextInputEvent,
|
||||
} from '../tools/tool.types.ts';
|
||||
import { ActorEvent, type MouseClickEvent, type TextInputEvent } from '../tools/tool.types.ts';
|
||||
import {
|
||||
activeToolAcceptsText,
|
||||
activeToolWantsFreeText,
|
||||
@@ -63,14 +56,8 @@ import { drawInputField } from './input-field-draw.ts';
|
||||
import { ctrlShortcut } from './input-shortcut.ts';
|
||||
import { wheelZoom } from './input-wheel.ts';
|
||||
import { attachTouch } from './input-touch.ts';
|
||||
import {
|
||||
ABSOLUTE_POINT_REGEXP,
|
||||
ABSOLUTE_POLAR_REGEXP,
|
||||
NUMBER_REGEXP,
|
||||
polarToPoint,
|
||||
RELATIVE_POINT_REGEXP,
|
||||
RELATIVE_POLAR_REGEXP,
|
||||
} from './input-patterns.ts';
|
||||
import { ANGLE_LOCK_REGEXP, parsePointText, type PointTextEvent } from './input-patterns.ts';
|
||||
import { angleLockHint, setAngleLock } from './input-angle-lock.ts';
|
||||
|
||||
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
|
||||
const DRAG_SELECT_MIN_PX = 4;
|
||||
@@ -135,6 +122,8 @@ export class InputController {
|
||||
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
|
||||
texts.push(`${roundedX},${roundedY}`);
|
||||
}
|
||||
const lockHint = angleLockHint();
|
||||
if (lockHint) texts.push(lockHint);
|
||||
// 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO)
|
||||
texts.push(...this.getCommandSuggestions());
|
||||
// 입력 글(조합 중인 한글도 함께)
|
||||
@@ -213,7 +202,7 @@ export class InputController {
|
||||
);
|
||||
const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
|
||||
|
||||
getActiveToolActor()?.send({
|
||||
this.sendPointEvent({
|
||||
type: ActorEvent.MOUSE_CLICK,
|
||||
worldMouseLocation,
|
||||
screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation),
|
||||
@@ -448,6 +437,7 @@ export class InputController {
|
||||
}
|
||||
|
||||
public handleEscapeKey() {
|
||||
setAngleLock(null);
|
||||
if (getSelectedEntities().length > 0) {
|
||||
// Deselect entities
|
||||
setSelectedEntityIds([]);
|
||||
@@ -495,17 +485,20 @@ export class InputController {
|
||||
if (wasSelectTool && getActiveToolActor()?.getSnapshot()?.status === 'done') {
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
}
|
||||
} else if (activeToolCanHandleTextInput) {
|
||||
console.log('TEXT_INPUT: ', {
|
||||
text: this.text,
|
||||
activeTool: getActiveToolActor(),
|
||||
});
|
||||
// Send the text to the active tool
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.TEXT_INPUT,
|
||||
value: this.text,
|
||||
} as TextInputEvent);
|
||||
} else if (activeToolWantsFreeText()) {
|
||||
// 글 단계(TEXT · MTEXT · 표 칸 …)는 친 그대로 — 숫자 · 명령 이름도 글
|
||||
this.sendText();
|
||||
} else if (ANGLE_LOCK_REGEXP.test(this.text)) {
|
||||
// 각도 고정 `<30` — 다음 한 점만 그 방향
|
||||
setAngleLock(Number.parseFloat((ANGLE_LOCK_REGEXP.exec(this.text) as RegExpExecArray)[1]));
|
||||
this.text = '';
|
||||
} else if (this.pointEventFromText()) {
|
||||
// 거리 `100` · `x,y` · `@x,y` · `@d<a` · `d<a`
|
||||
this.sendPointEvent(this.pointEventFromText() as PointTextEvent);
|
||||
this.text = '';
|
||||
} else if (activeToolCanHandleTextInput) {
|
||||
// 지금 단계의 선택지(배열 L/R · 숫자 단계 글)
|
||||
this.sendText();
|
||||
} else if (resolveCommandInput(this.text)) {
|
||||
// 명령 이름 또는 AutoCAD 별칭을 입력했다. 예: L, LINE, REC
|
||||
const command = resolveCommandInput(this.text);
|
||||
@@ -513,91 +506,37 @@ export class InputController {
|
||||
runCommand(command);
|
||||
}
|
||||
this.text = '';
|
||||
} else if (NUMBER_REGEXP.test(this.text)) {
|
||||
console.log(' NUMBER_INPUT: ', {
|
||||
text: this.text,
|
||||
activeTool: getActiveToolActor(),
|
||||
});
|
||||
// User entered a number. eg: 100
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.NUMBER_INPUT,
|
||||
value: Number.parseFloat(this.text),
|
||||
worldMouseLocation:
|
||||
getSnapPointOnAngleGuide()?.point ||
|
||||
getSnapPoint()?.point ||
|
||||
getScreenCanvasDrawController().getWorldMouseLocation(),
|
||||
} as NumberInputEvent);
|
||||
this.text = '';
|
||||
} else if (ABSOLUTE_POINT_REGEXP.test(this.text)) {
|
||||
console.log('ABSOLUTE_POINT_INPUT: ', {
|
||||
text: this.text,
|
||||
activeTool: getActiveToolActor(),
|
||||
});
|
||||
// User entered coordinates to an absolute point on the canvas. eg: 100, 200
|
||||
const match = ABSOLUTE_POINT_REGEXP.exec(this.text);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
const x = Number.parseFloat(match[1]);
|
||||
const y = Number.parseFloat(match[2]);
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.ABSOLUTE_POINT_INPUT,
|
||||
value: new Point(x, y),
|
||||
} as AbsolutePointInputEvent);
|
||||
this.text = '';
|
||||
} else if (RELATIVE_POINT_REGEXP.test(this.text)) {
|
||||
console.log('RELATIVE_POINT_INPUT: ', {
|
||||
text: this.text,
|
||||
activeTool: getActiveToolActor(),
|
||||
});
|
||||
// User entered coordinates to a relative point on the canvas. eg: @100, 200
|
||||
const match = RELATIVE_POINT_REGEXP.exec(this.text);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
const x = Number.parseFloat(match[1]);
|
||||
const y = Number.parseFloat(match[2]);
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.RELATIVE_POINT_INPUT,
|
||||
value: new Point(x, y),
|
||||
} as RelativePointInputEvent);
|
||||
this.text = '';
|
||||
} else if (RELATIVE_POLAR_REGEXP.test(this.text)) {
|
||||
// 직전 점에서 거리·각도로 이동. 예: @100<45
|
||||
const match = RELATIVE_POLAR_REGEXP.exec(this.text);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.RELATIVE_POINT_INPUT,
|
||||
value: polarToPoint(Number.parseFloat(match[1]), Number.parseFloat(match[2])),
|
||||
} as RelativePointInputEvent);
|
||||
this.text = '';
|
||||
} else if (ABSOLUTE_POLAR_REGEXP.test(this.text)) {
|
||||
// 원점에서 거리·각도로 지정한 점. 예: 100<45
|
||||
const match = ABSOLUTE_POLAR_REGEXP.exec(this.text);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.ABSOLUTE_POINT_INPUT,
|
||||
value: polarToPoint(Number.parseFloat(match[1]), Number.parseFloat(match[2])),
|
||||
} as AbsolutePointInputEvent);
|
||||
this.text = '';
|
||||
} else {
|
||||
console.log('TEXT_INPUT: ', {
|
||||
text: this.text,
|
||||
activeTool: getActiveToolActor(),
|
||||
});
|
||||
// Send the text to the active tool
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.TEXT_INPUT,
|
||||
value: this.text,
|
||||
} as TextInputEvent);
|
||||
this.text = '';
|
||||
this.sendText();
|
||||
}
|
||||
}
|
||||
|
||||
private sendText() {
|
||||
console.log('TEXT_INPUT: ', { text: this.text, activeTool: getActiveToolActor() });
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.TEXT_INPUT,
|
||||
value: this.text,
|
||||
} as TextInputEvent);
|
||||
this.text = '';
|
||||
}
|
||||
|
||||
/** 친 글을 점 · 거리 사건으로 — 거리의 방향 = 스냅 · 가이드 · 커서 */
|
||||
private pointEventFromText(): PointTextEvent | null {
|
||||
return parsePointText(
|
||||
this.text,
|
||||
() =>
|
||||
getSnapPointOnAngleGuide()?.point ||
|
||||
getSnapPoint()?.point ||
|
||||
getScreenCanvasDrawController().getWorldMouseLocation()
|
||||
);
|
||||
}
|
||||
|
||||
/** 점 하나를 도구에 보냄 — 각도 고정은 이 한 점으로 끝 */
|
||||
private sendPointEvent(event: PointTextEvent | MouseClickEvent) {
|
||||
getActiveToolActor()?.send(event);
|
||||
setAngleLock(null);
|
||||
}
|
||||
|
||||
public handleUndo(evt: KeyboardEvent) {
|
||||
evt.preventDefault();
|
||||
undo();
|
||||
|
||||
@@ -1,17 +1,65 @@
|
||||
/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 꼴 */
|
||||
/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 · 각도 고정 꼴 */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import {
|
||||
type AbsolutePointInputEvent,
|
||||
ActorEvent,
|
||||
type NumberInputEvent,
|
||||
type RelativePointInputEvent,
|
||||
} from '../tools/tool.types.ts';
|
||||
|
||||
export const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/;
|
||||
/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */
|
||||
const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)';
|
||||
/** 부호 있는 실수 한 개 — `-5` · `.5` · `1.5` */
|
||||
const SIGNED = '(-?(?:[0-9]+(?:[.][0-9]+)?|[.][0-9]+))';
|
||||
export const NUMBER_REGEXP = new RegExp(`^${SIGNED}$`);
|
||||
export const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`);
|
||||
export const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`);
|
||||
/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */
|
||||
export const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`);
|
||||
export const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`);
|
||||
/** 각도 고정 — `<30` (다음 한 점만 그 방향) */
|
||||
export const ANGLE_LOCK_REGEXP = new RegExp(`^<[ ]*${SIGNED}$`);
|
||||
|
||||
/** 거리·각도(도)를 x·y 변위로 바꾼다 */
|
||||
/** 거리·각도(도)를 x·y 변위로 바꾼다 — 각도 = 동쪽 0° 반시계 + */
|
||||
export function polarToPoint(distance: number, degrees: number): Point {
|
||||
const radians = (degrees * Math.PI) / 180;
|
||||
return new Point(distance * Math.cos(radians), distance * Math.sin(radians));
|
||||
}
|
||||
|
||||
export type PointTextEvent = NumberInputEvent | AbsolutePointInputEvent | RelativePointInputEvent;
|
||||
|
||||
/** 친 글 → 점 · 거리 사건(숫자 · `x,y` · `@x,y` · `@d<a` · `d<a`) · 아니면 null · 커서는 숫자일 때만 읽음 */
|
||||
export function parsePointText(text: string, cursor: () => Point): PointTextEvent | null {
|
||||
const num = (value: string) => Number.parseFloat(value);
|
||||
let match = NUMBER_REGEXP.exec(text);
|
||||
if (match) {
|
||||
return { type: ActorEvent.NUMBER_INPUT, value: num(match[1]), worldMouseLocation: cursor() };
|
||||
}
|
||||
match = ABSOLUTE_POINT_REGEXP.exec(text);
|
||||
if (match) {
|
||||
return {
|
||||
type: ActorEvent.ABSOLUTE_POINT_INPUT,
|
||||
value: new Point(num(match[1]), num(match[2])),
|
||||
};
|
||||
}
|
||||
match = RELATIVE_POINT_REGEXP.exec(text);
|
||||
if (match) {
|
||||
return {
|
||||
type: ActorEvent.RELATIVE_POINT_INPUT,
|
||||
value: new Point(num(match[1]), num(match[2])),
|
||||
};
|
||||
}
|
||||
match = RELATIVE_POLAR_REGEXP.exec(text);
|
||||
if (match) {
|
||||
return {
|
||||
type: ActorEvent.RELATIVE_POINT_INPUT,
|
||||
value: polarToPoint(num(match[1]), num(match[2])),
|
||||
};
|
||||
}
|
||||
match = ABSOLUTE_POLAR_REGEXP.exec(text);
|
||||
if (match) {
|
||||
return {
|
||||
type: ActorEvent.ABSOLUTE_POINT_INPUT,
|
||||
value: polarToPoint(num(match[1]), num(match[2])),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -176,7 +176,9 @@ export function createSequenceTool(config: SequenceToolConfig) {
|
||||
|
||||
const drawPreview = ({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
if (!config.preview) return;
|
||||
const ghosts = config.preview(makeInput(context.values, cursorPoint(event), context.picks));
|
||||
// 각도 고정(`<30`) 중이면 커서를 고정 방향 위로
|
||||
const cursor = getPointFromEvent(lastPoint(context.values), event as DrawEvent);
|
||||
const ghosts = config.preview(makeInput(context.values, cursor, context.picks));
|
||||
setGhostHelperEntities(ghosts);
|
||||
};
|
||||
|
||||
@@ -281,8 +283,10 @@ export function createSequenceTool(config: SequenceToolConfig) {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = { actions: pushPoint, target };
|
||||
on.ABSOLUTE_POINT_INPUT = { actions: pushPoint, target };
|
||||
on.RELATIVE_POINT_INPUT = { actions: pushPoint, target };
|
||||
on.NUMBER_INPUT = { actions: pushPoint, target };
|
||||
// 거리 · 상대 좌표는 기준점(앞 점)이 있을 때만 — 첫 점이면 무시(도구가 멈추지 않게)
|
||||
const hasBase = ({ context }: { context: SequenceContext }) => !!lastPoint(context.values);
|
||||
on.RELATIVE_POINT_INPUT = { guard: hasBase, actions: pushPoint, target };
|
||||
on.NUMBER_INPUT = { guard: hasBase, actions: pushPoint, target };
|
||||
if (config.repeatLastStep && index === lastIndex) {
|
||||
on.ENTER = { target: COMMIT_STATE };
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export const lineToolStateMachine = createMachine(
|
||||
const activeLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
(event as DrawEvent).drawController.getWorldMouseLocation()
|
||||
getPointFromEvent(context.startPoint, event as DrawEvent)
|
||||
);
|
||||
activeLine.lineColor = getActiveLineColor();
|
||||
activeLine.lineWidth = getActiveLineWidth();
|
||||
|
||||
@@ -149,3 +149,81 @@
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */
|
||||
.m02-hopyo-sheet__dialog > .ui-modal__body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 22rem;
|
||||
gap: var(--spacing-8);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__item:hover {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__item.is-current {
|
||||
border-left-color: var(--color-accent);
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__no {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__result {
|
||||
max-height: 40%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__result:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
|
||||
@@ -34,6 +35,22 @@ export interface HopyoBrief {
|
||||
금액: Money;
|
||||
/** [들어감 수, 줄 수] */
|
||||
맞춤: [number, number];
|
||||
/** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */
|
||||
행?: [number, number];
|
||||
}
|
||||
|
||||
/** 엑셀 보기 창 목록 한 줄 — 호표 · 단가산출 블록 행 범위 */
|
||||
export interface HopyoSpan {
|
||||
번: number;
|
||||
이름: string;
|
||||
행: [number, number];
|
||||
}
|
||||
|
||||
/** 엑셀 보기 창 격자(329) — 시트 「일위대가」 · 「단가산출」 */
|
||||
export interface HopyoGrid {
|
||||
책: Workbook;
|
||||
호표: HopyoSpan[];
|
||||
단산: HopyoSpan[];
|
||||
}
|
||||
|
||||
export interface HopyoView extends HopyoFile {
|
||||
@@ -136,6 +153,10 @@ export const listHopyo = async (scope: HopyoScope): Promise<HopyoFile[]> =>
|
||||
export const readHopyo = (scope: HopyoScope, key: string): Promise<HopyoView> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}`));
|
||||
|
||||
/** 격자 — 없으면 404 「격자 없음 — 다시 올리면 보임」(옛 파일) */
|
||||
export const readHopyoGrid = (scope: HopyoScope, key: string): Promise<HopyoGrid> =>
|
||||
call(at(scope, `/hopyo-grid/${enc(key)}`));
|
||||
|
||||
export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise<HopyoOne> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}/${no}`));
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* M02_MasterTemplete_Hopyo_Pick.ts
|
||||
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) ·
|
||||
* 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로
|
||||
* 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림. 프로젝트 층엔 없음.
|
||||
* 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림 · 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 ·
|
||||
* 고른 호표마다 참조 줄). 프로젝트 층엔 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
@@ -25,7 +26,42 @@ export function withHopyo(lib: LibraryPick): LibraryPick {
|
||||
{ value: "precombo", text: tc("Kind_Combo") },
|
||||
{ value: HOPYO_KIND, text: "실무 호표" },
|
||||
],
|
||||
more: async () => (await listHopyoPicks(scope)).map((row) => hopyoExtra(row, lib)),
|
||||
more: async () => {
|
||||
const rows = await listHopyoPicks(scope);
|
||||
const files = new Map<string, HopyoPickRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.층}|${row.주인 ?? ""}|${row.키}`;
|
||||
files.set(key, [...(files.get(key) ?? []), row]);
|
||||
}
|
||||
return [
|
||||
...rows.map((row) => hopyoExtra(row, lib)),
|
||||
...[...files].map(([key, list]) => sheetExtra(key, list[0], list.length, lib)),
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 파일 하나 — 엑셀 보기 창에서 여러 호표를 골라 한 번에(만드는 층 = 문서 층) */
|
||||
export function sheetExtra(key: string, row: HopyoPickRow, n: number, lib: LibraryPick): PickExtra {
|
||||
return {
|
||||
kind: HOPYO_KIND,
|
||||
key: `${key}|엑셀`,
|
||||
tag: "실무 호표",
|
||||
name: "엑셀로 보기 · 여러 호표",
|
||||
sub: `${row.파일} · ${LAYER_TEXT[row.층] ?? row.층}`,
|
||||
unit: `호표 ${n}`,
|
||||
onPick: () =>
|
||||
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) =>
|
||||
m.openHopyoSheet({
|
||||
scope: { layer: row.층, owner: row.주인 },
|
||||
key: row.키,
|
||||
파일: row.파일,
|
||||
canWrite: true,
|
||||
target: { layer: lib.layer, owner: lib.owner },
|
||||
onImport: (got, h) =>
|
||||
lib.pick(got.참조, got.이름, h.이름.slice(0, ALIAS_MAX).trim() || null),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Sheet.ts
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
|
||||
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 맞춤 n/m) + [고른 n개 불러오기].
|
||||
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
|
||||
* · 불러오기 = 고른 호표마다 차례로 사전조합 만들기(`/hopyo-build`) → 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
|
||||
* `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 불러오기는 됨
|
||||
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import {
|
||||
buildHopyo,
|
||||
readHopyo,
|
||||
readHopyoGrid,
|
||||
type HopyoBrief,
|
||||
type HopyoBuilt,
|
||||
type HopyoGrid,
|
||||
type HopyoScope,
|
||||
type HopyoSpan,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { builtWord } from "./M02_MasterTemplete_Hopyo_View";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export interface HopyoSheetOptions {
|
||||
/** 실무자료 파일 층 */
|
||||
scope: HopyoScope;
|
||||
key: string;
|
||||
파일: string;
|
||||
/** 만들 층 쓰기 권한 — 없으면 체크 · 불러오기 없음(보기만) */
|
||||
canWrite: boolean;
|
||||
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
|
||||
target?: HopyoScope;
|
||||
/** 처음 고를 호표 번(호표 화면 [엑셀에서 보기]) */
|
||||
호표?: number;
|
||||
/** 처음 보일 단가산출 행(330-3 근거 [엑셀에서 보기]) */
|
||||
단산행?: number | [number, number];
|
||||
/** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */
|
||||
onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void;
|
||||
}
|
||||
|
||||
const ILWI = "일위대가";
|
||||
const DANSAN = "단가산출";
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
/** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */
|
||||
export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null =>
|
||||
list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null;
|
||||
|
||||
/** 호표 영역 A1 — B열 ~ 시트 끝 열(북부청 = `B20:O24`) */
|
||||
export const spanText = (행: [number, number], last: string): string => `B${행[0]}:${last}${행[1]}`;
|
||||
|
||||
/** 시트 끝 열 글자 — 칸 · 열 정보 중 가장 오른쪽 */
|
||||
export function lastColumn(sheet: Pick<Sheet, "칸" | "열">): string {
|
||||
let best = "A";
|
||||
for (const k of [...Object.keys(sheet.칸), ...Object.keys(sheet.열 ?? {})]) {
|
||||
const c = /^[A-Z]+/.exec(k)?.[0];
|
||||
if (c && (c.length > best.length || (c.length === best.length && c > best))) best = c;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 단가산출 행 → 행 전체 범위 A1(`4:9`) */
|
||||
export const rowsText = (행: number | [number, number]): string =>
|
||||
typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`;
|
||||
|
||||
export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
let sheet: SpreadsheetHandle | null = null;
|
||||
let closed = false;
|
||||
let briefs: HopyoBrief[] = [];
|
||||
let spans: HopyoSpan[] = [];
|
||||
let ilwi = ""; // 일위대가 시트 id
|
||||
let last = "O";
|
||||
let at = { 시트: "", 칸: "", 범위: "" }; // 시트 고름
|
||||
let current: number | null = opt.호표 ?? null;
|
||||
const checked = new Set<number>();
|
||||
const items = new Map<number, HTMLElement>();
|
||||
|
||||
const left = el("div", {
|
||||
className: "m02-hopyo-sheet__grid",
|
||||
children: [note("엑셀을 읽는 중…")],
|
||||
});
|
||||
const search = el("input", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기" },
|
||||
}) as HTMLInputElement;
|
||||
const list = el("div", { className: "m02-hopyo-sheet__list" });
|
||||
const count = note("");
|
||||
const go = createButton({ label: "고른 0개 불러오기", variant: "filled", size: "sm" });
|
||||
go.disabled = true;
|
||||
const result = el("div", { className: "m02-hopyo-sheet__result" });
|
||||
const right = el("div", {
|
||||
className: "m02-hopyo-sheet__side",
|
||||
children: [
|
||||
search,
|
||||
el("div", { className: "m02-hopyo__bar", children: [count, ...(opt.canWrite ? [go] : [])] }),
|
||||
list,
|
||||
result,
|
||||
],
|
||||
});
|
||||
|
||||
openModal({
|
||||
title: `엑셀 보기 — ${opt.파일}`,
|
||||
closeLabel: "닫기",
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo-sheet__dialog",
|
||||
beforeClose: async () => {
|
||||
closed = true;
|
||||
sheet?.destroy();
|
||||
return true;
|
||||
},
|
||||
mount: (body) =>
|
||||
body.append(el("div", { className: "m02-hopyo-sheet", children: [left, right] })),
|
||||
});
|
||||
|
||||
const paintGo = (): void => {
|
||||
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 불러오기`;
|
||||
go.disabled = !checked.size;
|
||||
};
|
||||
|
||||
/** 목록 강조 · 보이게(시트 고름은 그대로) */
|
||||
function mark(no: number | null): void {
|
||||
items.get(current ?? NaN)?.classList.remove("is-current");
|
||||
current = no;
|
||||
const row = items.get(no ?? NaN);
|
||||
row?.classList.add("is-current");
|
||||
row?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
/** 호표 하나 강조 — 목록 · 시트 영역(같은 범위면 다시 안 부름) */
|
||||
function focus(no: number): void {
|
||||
mark(no);
|
||||
const span = spans.find((h) => h.번 === no)?.행 ?? briefs.find((h) => h.번 === no)?.행;
|
||||
if (!sheet || !span) return;
|
||||
const text = spanText(span, last);
|
||||
if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi);
|
||||
}
|
||||
|
||||
function paintList(): void {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
items.clear();
|
||||
const shown = briefs.filter(
|
||||
(h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q),
|
||||
);
|
||||
list.replaceChildren(
|
||||
...shown.map((h) => {
|
||||
const box = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||
box.checked = checked.has(h.번);
|
||||
box.hidden = !opt.canWrite;
|
||||
box.addEventListener("click", (e) => e.stopPropagation());
|
||||
box.addEventListener("change", () => {
|
||||
if (box.checked) checked.add(h.번);
|
||||
else checked.delete(h.번);
|
||||
paintGo();
|
||||
});
|
||||
const row = el("div", {
|
||||
className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`,
|
||||
attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() },
|
||||
children: [
|
||||
box,
|
||||
el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }),
|
||||
el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }),
|
||||
note(`${h.맞춤[0]}/${h.맞춤[1]}`),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => focus(h.번));
|
||||
items.set(h.번, row);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
count.textContent = q ? `${shown.length} / ${briefs.length}` : `호표 ${briefs.length}`;
|
||||
}
|
||||
search.addEventListener("input", paintList);
|
||||
|
||||
async function mountSheet(grid: HopyoGrid): Promise<void> {
|
||||
const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet");
|
||||
if (closed) return;
|
||||
const book = structuredClone(grid.책);
|
||||
const first = book.시트.find((s) => s.이름 === ILWI) ?? book.시트[0];
|
||||
if (!first) return void left.replaceChildren(note("시트 없음"));
|
||||
ilwi = first.id;
|
||||
last = lastColumn(first);
|
||||
book.활성 = first.id;
|
||||
left.replaceChildren();
|
||||
sheet = createSpreadsheet(left, book, {
|
||||
readOnly: true,
|
||||
onSelect: (s) => {
|
||||
at = s;
|
||||
if (s.시트 !== ilwi) return;
|
||||
const no = hopyoAtRow(spans, Number(/\d+/.exec(s.칸)?.[0]));
|
||||
if (no !== null && no !== current) mark(no);
|
||||
},
|
||||
});
|
||||
// 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈)
|
||||
left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => {
|
||||
if ((e as MouseEvent).button !== 0 || at.시트 !== ilwi) return;
|
||||
const no = hopyoAtRow(spans, Number(/\d+/.exec(at.칸)?.[0]));
|
||||
if (no !== null) focus(no);
|
||||
});
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const [file, grid] = await Promise.all([
|
||||
readHopyo(opt.scope, opt.key),
|
||||
readHopyoGrid(opt.scope, opt.key).catch((error: unknown) => new Error(why(error))),
|
||||
]);
|
||||
if (closed) return;
|
||||
briefs = file.호표;
|
||||
spans = grid instanceof Error ? [] : grid.호표;
|
||||
paintList();
|
||||
if (grid instanceof Error) return void left.replaceChildren(note(grid.message));
|
||||
await mountSheet(grid);
|
||||
if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN);
|
||||
else if (current !== null) focus(current);
|
||||
} catch (error) {
|
||||
if (!closed) left.replaceChildren(note(`못 읽음 — ${why(error)}`));
|
||||
}
|
||||
}
|
||||
|
||||
async function importPicked(): Promise<void> {
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
const target = opt.target ?? opt.scope;
|
||||
const rows: HTMLElement[] = [];
|
||||
let ok = 0;
|
||||
go.disabled = true;
|
||||
for (const no of nos) {
|
||||
const h = briefs.find((x) => x.번 === no);
|
||||
if (!h || closed) continue;
|
||||
const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
|
||||
const got = await buildHopyo(target, from);
|
||||
ok += 1;
|
||||
opt.onImport?.(got, h);
|
||||
rows.push(
|
||||
el("tr", {
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
head,
|
||||
el("td", { text: `${got.참조.키} · ${builtWord(got, false)}` }),
|
||||
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
|
||||
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
|
||||
}
|
||||
result.replaceChildren(
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: ["호표", "사전조합", "빠진 줄", "값 차이"].map((t) =>
|
||||
el("th", { text: t }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
paintGo();
|
||||
showToast(`불러옴 ${ok} / ${nos.length}`, ok === nos.length ? "success" : "warning");
|
||||
}
|
||||
go.addEventListener("click", () => void importPicked());
|
||||
|
||||
void load();
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* M02_MasterTemplete_Hopyo_Side.ts
|
||||
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
|
||||
* [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면
|
||||
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만.
|
||||
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 ·
|
||||
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
@@ -66,6 +67,8 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success");
|
||||
await refresh();
|
||||
await show(got);
|
||||
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet");
|
||||
sheet.openHopyoSheet({ scope, key: got.키, 파일: got.파일, canWrite: opt.canWrite });
|
||||
})
|
||||
.catch((error) => showToast(why(error), "error"))
|
||||
.finally(() => (upload.disabled = !opt.canWrite));
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* M02_MasterTemplete_Hopyo_View.ts
|
||||
* 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 ·
|
||||
* [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 ·
|
||||
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기].
|
||||
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] ·
|
||||
* [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채).
|
||||
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
type HopyoTarget,
|
||||
type HopyoView,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
@@ -52,6 +54,9 @@ const why = (error: unknown): string => (error instanceof Error ? error.message
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
|
||||
/** 엑셀 보기 창(329) — 열 때 불러옴 */
|
||||
const openSheet = (o: HopyoSheetOptions): void =>
|
||||
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
|
||||
|
||||
const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
|
||||
el("div", {
|
||||
@@ -176,6 +181,12 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
note(`호표 ${file.호표수}`),
|
||||
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
||||
note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`),
|
||||
createButton({
|
||||
label: "엑셀로 보기",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({ scope: opt.scope, key, 파일: file.파일, canWrite: opt.canWrite }),
|
||||
}),
|
||||
...(opt.canWrite ? [dropButton(file)] : []),
|
||||
],
|
||||
}),
|
||||
@@ -240,6 +251,18 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
note(h.단위),
|
||||
note(`엑셀 단가 ${won(h.금액.계)}`),
|
||||
note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`),
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
파일: got.파일,
|
||||
canWrite: opt.canWrite,
|
||||
호표: no,
|
||||
}),
|
||||
}),
|
||||
...tools,
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -38,6 +38,8 @@ ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR",
|
||||
# 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다.
|
||||
# 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다.
|
||||
ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9"))
|
||||
# 추천 노선 큰 R — 곡선을 키울 때 탐색 경로에서 벗어나도 되는 가장 먼 거리(m) · 탐색 칸 하나
|
||||
ROUTE_RECOMMEND_MAX_GAP_M = float(os.getenv("ROUTE_RECOMMEND_MAX_GAP_M", "4.0"))
|
||||
# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존
|
||||
# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19).
|
||||
ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch")
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* CAD 키보드 연속 명령(PLAN 331 · 요구 224-7) — 오토캐드 방식.
|
||||
* 각도 고정 `<30` → 길이 · 시퀀스 첫 점 숫자 · 이동 · 복사 · 회전 키보드 값 · 선 중 C · U · 한 번 스냅 · 동적 입력 Tab.
|
||||
* 시험 쪽 flatten-js 는 다른 사본(「Illegal Parameters」) — 점은 앱 코드로만 만들고 숫자로 판정.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad keyboard-chain
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller";
|
||||
import { getAngleLock } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-angle-lock";
|
||||
import {
|
||||
getActiveToolActor,
|
||||
getEntities,
|
||||
setEntities,
|
||||
setSelectedEntityIds,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
import { runCommandInput } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command";
|
||||
import { initApplication } from "../../../B07_DesignDetail/openwebcad/test/helpers/init-application";
|
||||
import { CANVAS_HEIGHT } from "../../../B07_DesignDetail/openwebcad/test/helpers/tests.consts";
|
||||
|
||||
type Xy = { x: number; y: number };
|
||||
type Seg = { start: Xy; end: Xy };
|
||||
|
||||
const key = (controller: InputController, k: string) =>
|
||||
controller.handleKeyStroke({
|
||||
key: k,
|
||||
code: "",
|
||||
keyCode: 0,
|
||||
isComposing: false,
|
||||
shiftKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
target: null,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
/** 글자를 치고 Space(= Enter) */
|
||||
const type = (controller: InputController, text: string) => {
|
||||
for (const ch of text) key(controller, ch);
|
||||
key(controller, " ");
|
||||
};
|
||||
|
||||
/** 세계 좌표 (x, y) 로 커서 이동 — 시험 캔버스는 축척 1 · y 위 + */
|
||||
const move = (controller: InputController, x: number, y: number) =>
|
||||
controller.handleMouseMove({ clientX: x, clientY: CANVAS_HEIGHT - y } as MouseEvent);
|
||||
|
||||
const clickAt = (controller: InputController, x: number, y: number) => {
|
||||
move(controller, x, y);
|
||||
controller.handleMouseUp({
|
||||
button: 0,
|
||||
clientX: x,
|
||||
clientY: CANVAS_HEIGHT - y,
|
||||
target: null,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as MouseEvent);
|
||||
};
|
||||
|
||||
const lines = () =>
|
||||
getEntities()
|
||||
.filter((entity) => entity.getType() === "Line")
|
||||
.map((entity) => (entity as unknown as { segment: Seg }).segment);
|
||||
|
||||
const expectXy = (point: Xy, x: number, y: number) => {
|
||||
expect(point.x).toBeCloseTo(x, 3);
|
||||
expect(point.y).toBeCloseTo(y, 3);
|
||||
};
|
||||
|
||||
let controller: InputController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(toast.error).mockClear();
|
||||
controller = initApplication();
|
||||
setEntities([], true);
|
||||
setSelectedEntityIds([]);
|
||||
});
|
||||
|
||||
describe("331-1 각도 고정 → 길이", () => {
|
||||
it("L · 0,0 · <30 · 100 → (86.603, 50) · <90 · 50 → (86.603, 100)", () => {
|
||||
type(controller, "l");
|
||||
type(controller, "0,0");
|
||||
move(controller, 500, 500); // 커서 방향과 무관하게 고정 각도로
|
||||
type(controller, "<30");
|
||||
expect(getAngleLock()).toBe(30);
|
||||
type(controller, "100");
|
||||
expect(getAngleLock()).toBeNull(); // 한 점만
|
||||
type(controller, "<90");
|
||||
type(controller, "50");
|
||||
const drawn = lines();
|
||||
expect(drawn).toHaveLength(2);
|
||||
expectXy(drawn[0].start, 0, 0);
|
||||
expectXy(drawn[0].end, 86.603, 50);
|
||||
expectXy(drawn[1].end, 86.603, 100);
|
||||
});
|
||||
|
||||
it("고정 없이 숫자 = 마우스 방향", () => {
|
||||
type(controller, "l");
|
||||
type(controller, "0,0");
|
||||
move(controller, 0, 300);
|
||||
type(controller, "100");
|
||||
expectXy(lines()[0].end, 0, 100);
|
||||
});
|
||||
|
||||
it("고정 중 클릭 = 고정 방향 위로 내림 · Esc · 새 명령이면 풂", () => {
|
||||
type(controller, "l");
|
||||
type(controller, "0,0");
|
||||
type(controller, "<0");
|
||||
clickAt(controller, 60, 25); // 가이드에서 먼 자리(스냅 안 붙게)
|
||||
expectXy(lines()[0].end, 60, 0);
|
||||
type(controller, "<45");
|
||||
key(controller, "Escape");
|
||||
expect(getAngleLock()).toBeNull();
|
||||
type(controller, "<45");
|
||||
runCommandInput("CIRCLE");
|
||||
expect(getAngleLock()).toBeNull();
|
||||
});
|
||||
|
||||
it("음수 · .5 숫자 · 음수 각도", () => {
|
||||
type(controller, "l");
|
||||
type(controller, "0,0");
|
||||
type(controller, "<-90");
|
||||
type(controller, ".5");
|
||||
expectXy(lines()[0].end, 0, -0.5);
|
||||
type(controller, "<0");
|
||||
type(controller, "-10");
|
||||
expectXy(lines()[1].end, -10, -0.5);
|
||||
});
|
||||
|
||||
it("PLINE 첫 점에 숫자 · @x,y → 무시 · 도구 살아 있음 · 알림 0", () => {
|
||||
type(controller, "pline");
|
||||
type(controller, "100");
|
||||
type(controller, "@10,0");
|
||||
const snapshot = getActiveToolActor()?.getSnapshot();
|
||||
expect(snapshot?.status).toBe("active");
|
||||
expect(snapshot?.value).toBe("STEP_0");
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
type(controller, "0,0");
|
||||
type(controller, "<0");
|
||||
type(controller, "100");
|
||||
expect(getActiveToolActor()?.getSnapshot()?.context.values).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
/* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) ·
|
||||
* `M02_MasterTemplete_Hopyo_View.ts` 글 도우미를 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 ·
|
||||
* stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view} JSON. */
|
||||
* `M02_MasterTemplete_Hopyo_View.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를
|
||||
* 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 ·
|
||||
* stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view, sheet} JSON. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
@@ -55,6 +56,13 @@ function loadTs(file) {
|
||||
await pick.pickHopyo(input.rows[0], lib);
|
||||
const project = pick.withHopyo({ ...lib, layer: "project" });
|
||||
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts"));
|
||||
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
|
||||
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts"));
|
||||
await api.readHopyoGrid({ layer: "company", owner: "7" }, "HP-000001");
|
||||
const spans = [
|
||||
{ 번: 3, 이름: "임도표지석", 행: [20, 24] },
|
||||
{ 번: 4, 이름: "배수관", 행: [25, 30] },
|
||||
];
|
||||
const out = {
|
||||
calls,
|
||||
kinds: wrapped.kindFilter.map((k) => k.value),
|
||||
@@ -80,6 +88,12 @@ function loadTs(file) {
|
||||
view.builtWord({ 다시씀: true }, true),
|
||||
],
|
||||
},
|
||||
sheet: {
|
||||
at: [19, 20, 22, 24, 25, 30, 31].map((row) => sheet.hopyoAtRow(spans, row)),
|
||||
span: sheet.spanText([20, 24], sheet.lastColumn({ 칸: { A1: {}, N24: {} }, 열: { O: {} } })),
|
||||
last: [sheet.lastColumn({ 칸: { Z9: {}, AA1: {} } }), sheet.lastColumn({ 칸: {} })],
|
||||
rows: [sheet.rowsText(4345), sheet.rowsText([93, 101])],
|
||||
},
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})().catch((error) => {
|
||||
|
||||
@@ -65,6 +65,11 @@ const out = {
|
||||
})(),
|
||||
cycleSingle: sel.cycle(s1, cell(3, 2), 1, 0),
|
||||
usedEnd: addr.toA1(sel.usedEnd(s1).r, sel.usedEnd(s1).c),
|
||||
// 손잡이 select(범위) — 범위 글 → 고름(병합 늘림 · 여럿 · 틀린 글 null)
|
||||
ranges: [["B20:O24"], ["D4"], ["B20:O24", "A1:A2"], ["가1"], []].map((t) => {
|
||||
const s = sel.selectRanges(s1, t);
|
||||
return s && [addr.toA1(s.활성.r, s.활성.c), ...s.범위.map(addr.rangeToA1)].join("|");
|
||||
}),
|
||||
// 입력 글 → 칸
|
||||
textToCell: ["12", "1,234.5", "50%", "-1e3", "true", "'=12", "=", "=A1+1", "", "1,23", "abc"].map(
|
||||
(t) => ed.textToCell(t, { 서식: 4 }),
|
||||
|
||||
@@ -46,6 +46,11 @@ def test_cycle_in_selection(r):
|
||||
assert r["cycleSingle"] is None
|
||||
|
||||
|
||||
def test_select_ranges(r):
|
||||
# 엑셀 보기 창(329-2) 호표 영역 강조 — 활성 = 왼위 · 병합 C4:F4 · A1:H1 걸치면 늘림 · 틀린 글 · 빈 목록 null
|
||||
assert r["ranges"] == ["B20|B20:O24", "C4|C4:F4", "B20|B20:O24|A1:H2", None, None]
|
||||
|
||||
|
||||
def test_text_to_cell(r):
|
||||
got = r["textToCell"]
|
||||
assert [c.get("값", c.get("식")) for c in got] == [
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
"""요구 224-2 · PLAN 326 — 계획노선 편집 [측점 스냅].
|
||||
|
||||
짝: 파이썬 `B05_Profile_Engine_Sections_Core` (`_chainages` · `_design_curve_spans` ·
|
||||
`snap_curve_stations`) ↔ TS `B05_Profile_UI_RouteEdit_Frame.ts` (`stationsAlong` ·
|
||||
`curveSpans` · `snapCurveStations`). 켜면 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운
|
||||
하나를 floor(MC+0.5) 로 옮김 · 측점 없는 호는 안 심음 · 같은 m 하나로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
|
||||
|
||||
TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Frame.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
GRID = [float(value) for value in range(0, 1001, 20)]
|
||||
|
||||
|
||||
def _snap(spans: list[tuple[float, float]], stations: list[float] | None = None) -> list[float]:
|
||||
return [float(v) for v in core.snap_curve_stations(stations or GRID, spans)]
|
||||
|
||||
|
||||
# ── 파이썬 규칙 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_곡선_없음_그대로() -> None:
|
||||
assert _snap([]) == GRID
|
||||
|
||||
|
||||
def test_측점_없는_호는_안_심음() -> None:
|
||||
assert _snap([(621.0, 639.0)]) == GRID
|
||||
|
||||
|
||||
def test_호_안_하나를_정수_MC_로() -> None:
|
||||
out = _snap([(612.0, 634.0)]) # MC 623
|
||||
assert 623.0 in out and 620.0 not in out and len(out) == len(GRID)
|
||||
|
||||
|
||||
def test_호_안_둘이면_MC_에_가까운_하나만() -> None:
|
||||
out = _snap([(616.1, 646.5)]) # MC 631.3 — 640(8.7) 이 620(11.3) 보다 가까움
|
||||
assert 631.0 in out and 640.0 not in out and 620.0 in out
|
||||
|
||||
|
||||
def test_BC_위_측점은_호_밖() -> None:
|
||||
assert _snap([(620.0, 630.0)]) == GRID
|
||||
assert _snap([(610.0, 620.0)]) == GRID # EC 위도 밖
|
||||
|
||||
|
||||
def test_반_올림() -> None:
|
||||
out = _snap([(611.0, 640.0)]) # MC 625.5 → 626 · 640 = EC 위라 밖
|
||||
assert 626.0 in out and 620.0 not in out and 640.0 in out
|
||||
|
||||
|
||||
def test_같은_m_는_하나로() -> None:
|
||||
# 20.3 이 MC 20.35 → 20 으로 옮겨 이웃 20 과 같은 m — 하나만 남음
|
||||
assert _snap([(20.1, 20.6)], [0.0, 20.0, 20.3, 40.0]) == [0.0, 20.0, 40.0]
|
||||
|
||||
|
||||
# ── 파이썬 ↔ TS 거울 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _arc(cx: float, cy: float, radius: float, start: float, end: float, count: int) -> list:
|
||||
return [
|
||||
[cx + radius * math.cos(angle), cy + radius * math.sin(angle)]
|
||||
for angle in np.linspace(start, end, count)
|
||||
]
|
||||
|
||||
|
||||
def _bend_route() -> tuple[list, list]:
|
||||
"""직선 300 m → R 40 호 → 직선 — 곡선표 한 줄 + 노선 밖 곡선 · 방향 없는 곡선."""
|
||||
arc = _arc(300.0, 40.0, 40.0, -math.pi / 2, 0.3, 60)
|
||||
line = [[0.0, 0.0], *arc, [arc[-1][0] - 200 * math.sin(0.3), arc[-1][1] + 200 * math.cos(0.3)]]
|
||||
curves = [
|
||||
{"start": arc[0], "apex": [340.0, 0.0], "end": arc[-1], "radius_m": 40.0},
|
||||
{"start": [0.0, 500.0], "apex": [10.0, 510.0], "end": [20.0, 500.0], "radius_m": 30.0},
|
||||
{"start": [50.0, 0.0], "apex": [60.0, 0.0], "end": [70.0, 0.0], "radius_m": 30.0},
|
||||
{"start": [80.0, 0.0], "apex": [90.0, 1.0], "end": [100.0, 0.0], "radius_m": None},
|
||||
]
|
||||
return line, curves
|
||||
|
||||
|
||||
CASES = [
|
||||
{"line": [[0, 0], [1000, 0]], "interval": 20, "end": True, "curves": []},
|
||||
{
|
||||
"line": [[0, 0], [1000.3, 0]],
|
||||
"interval": 20,
|
||||
"end": True,
|
||||
"curves": [{"start": [616.1, 0], "apex": [631.3, 3], "end": [646.5, 0], "radius_m": 40}],
|
||||
},
|
||||
{
|
||||
"line": [[0, 0], [500, 0], [500, 0], [700, 10]],
|
||||
"interval": 20,
|
||||
"end": False,
|
||||
"curves": [
|
||||
{"start": [611, 0], "apex": [625.5, -2], "end": [640, 0], "radius_m": 25},
|
||||
{"start": [621, 0], "apex": [630, 2], "end": [639, 0], "radius_m": 25},
|
||||
{"start": [100, 0], "apex": [110, 2], "end": [112, 0], "radius_m": 25},
|
||||
],
|
||||
},
|
||||
{"line": _bend_route()[0], "interval": 20, "end": True, "curves": _bend_route()[1]},
|
||||
{"line": _bend_route()[0], "interval": 7.5, "end": True, "curves": _bend_route()[1]},
|
||||
]
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import * as frame from "./B05_Profile_UI_RouteEdit_Frame.js";
|
||||
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const out = {
|
||||
tolerance: frame.DESIGN_CURVE_MATCH_TOLERANCE_M,
|
||||
edge: frame.CURVE_STATION_EDGE_M,
|
||||
rows: cases.map((item) => {
|
||||
const grid = frame.stationsAlong(item.line, item.interval, item.end);
|
||||
const spans = frame.curveSpans(item.line, item.curves);
|
||||
return { grid, spans, snapped: frame.snapCurveStations(grid, spans) };
|
||||
}),
|
||||
};
|
||||
writeFileSync(process.argv[3], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _python_row(case: dict) -> dict:
|
||||
points, route_chainage = core._clean_polyline(np.asarray(case["line"], dtype=np.float64))
|
||||
grid = core._chainages(float(route_chainage[-1]), float(case["interval"]), case["end"])
|
||||
spans = core._design_curve_spans(points, route_chainage, case["curves"])
|
||||
return {
|
||||
"grid": [float(v) for v in grid],
|
||||
"spans": [[span[0], span[1]] for span in spans],
|
||||
"snapped": [float(v) for v in core.snap_curve_stations(grid, spans)],
|
||||
}
|
||||
|
||||
|
||||
def _ts_rows(tmp_path: Path) -> dict:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(TS_FILE),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.glob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
payload = tmp_path / "input.json"
|
||||
result = tmp_path / "output.json"
|
||||
payload.write_text(json.dumps(CASES), encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_스냅_셈이_두_쪽에서_같다(tmp_path: Path) -> None:
|
||||
actual = _ts_rows(tmp_path)
|
||||
assert actual["tolerance"] == core.DESIGN_CURVE_MATCH_TOLERANCE_M
|
||||
assert actual["edge"] == core.CURVE_STATION_EDGE_M
|
||||
moved = 0
|
||||
for index, case in enumerate(CASES):
|
||||
expected = _python_row(case)
|
||||
row = actual["rows"][index]
|
||||
assert row["grid"] == pytest.approx(expected["grid"], abs=1e-9), f"사례 {index} 격자"
|
||||
assert len(row["spans"]) == len(expected["spans"]), f"사례 {index} 곡선 수"
|
||||
for got, want in zip(row["spans"], expected["spans"], strict=True):
|
||||
assert got == pytest.approx(want, abs=1e-9), f"사례 {index} 곡선"
|
||||
assert row["snapped"] == pytest.approx(expected["snapped"], abs=1e-9), f"사례 {index} 스냅"
|
||||
moved += row["snapped"] != row["grid"]
|
||||
assert moved >= 3 # 옮김이 실제로 일어난 사례를 견줌
|
||||
|
||||
|
||||
def test_짝_머리글이_서로를_가리킨다() -> None:
|
||||
text = TS_FILE.read_text(encoding="utf-8")
|
||||
assert "B05_Profile_Engine_Sections_Core.py" in text and "snap_curve_stations" in text
|
||||
assert "snapCurveStations" in (core.__doc__ or "")
|
||||
|
||||
|
||||
# ── generate_sections ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _Flat:
|
||||
def sample_xy(self, xy):
|
||||
count = len(np.asarray(xy, dtype=np.float64))
|
||||
return np.full(count, 100.0), np.ones(count, dtype=bool)
|
||||
|
||||
|
||||
_LINE = [[0.0, 0.0, 100.0], [1000.3, 0.0, 100.0]]
|
||||
_CURVES = [{"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0}]
|
||||
|
||||
|
||||
def _stations(snap: bool | None, extras: tuple = ()) -> list[dict]:
|
||||
kwargs = {} if snap is None else {"station_snap": snap}
|
||||
result = core.generate_sections(
|
||||
_LINE,
|
||||
_Flat(),
|
||||
core.SectionGenerationOptions(extra_stations=extras),
|
||||
design_curves=_CURVES,
|
||||
**kwargs,
|
||||
)
|
||||
return result["longitudinal"]["stations"]
|
||||
|
||||
|
||||
def test_끔은_지금과_같음() -> None:
|
||||
assert _stations(False) == _stations(None)
|
||||
assert 640.0 in [s["chainage_m"] for s in _stations(False)]
|
||||
|
||||
|
||||
def test_켬은_640_없고_631_있음() -> None:
|
||||
by_chainage = {s["chainage_m"]: s for s in _stations(True)}
|
||||
assert 640.0 not in by_chainage and 631.0 in by_chainage
|
||||
assert by_chainage[631.0]["kind"] == "regular"
|
||||
assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경
|
||||
assert len(by_chainage) == len(_stations(False))
|
||||
|
||||
|
||||
def test_631_에_관이면_grid() -> None:
|
||||
by_chainage = {s["chainage_m"]: s for s in _stations(True, ((631.0, "관"),))}
|
||||
assert by_chainage[631.0]["kind"] == "irregular"
|
||||
assert by_chainage[631.0]["grid"] is True
|
||||
# 640 에 놓인 관은 스냅 뒤 격자가 아니라 따로 선 측점
|
||||
off_grid = {s["chainage_m"]: s for s in _stations(True, ((640.0, "관"),))}
|
||||
assert off_grid[640.0]["kind"] == "irregular" and "grid" not in off_grid[640.0]
|
||||
|
||||
|
||||
# ── 서버 저장 · 정본 (326-2) ─────────────────────────────────────────────────
|
||||
|
||||
_NODES = [(0.0, 0.0), (300.0, 0.0), (300.0, 300.0)]
|
||||
|
||||
|
||||
def _working(tmp_path: Path) -> Path:
|
||||
from common_util.common_util_route_geometry import planned_route_working_path
|
||||
|
||||
path = planned_route_working_path(tmp_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def test_확인_때_곡선표에_적고_읽음(tmp_path: Path) -> None:
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
|
||||
|
||||
path = _working(tmp_path)
|
||||
assert load_station_snap(tmp_path) is False # 파일 없음
|
||||
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
|
||||
assert "station_snap" not in json.loads(replan._curves_path(path).read_text("utf-8"))
|
||||
assert load_station_snap(tmp_path) is False # 칸 없음 = 끔(옛 프로젝트)
|
||||
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True)
|
||||
assert load_station_snap(tmp_path) is True
|
||||
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=False)
|
||||
assert load_station_snap(tmp_path) is False
|
||||
|
||||
|
||||
def _patch_replan(monkeypatch, tmp_path: Path):
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
|
||||
async def paths(_project_id):
|
||||
return tmp_path, "stored"
|
||||
|
||||
async def criteria(_project_id):
|
||||
return 30.0, 0.0, 0.0
|
||||
|
||||
monkeypatch.setattr(replan, "_project_paths", paths)
|
||||
monkeypatch.setattr(replan, "_plan_criteria", criteria)
|
||||
return replan
|
||||
|
||||
|
||||
def test_실패하면_곡선표_노드도_되돌림(tmp_path: Path, monkeypatch) -> None:
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
|
||||
replan = _patch_replan(monkeypatch, tmp_path)
|
||||
|
||||
async def broken(*_args, **_kwargs):
|
||||
return {"error": "체인 끊김"}
|
||||
|
||||
monkeypatch.setattr(replan, "_recompute", broken)
|
||||
path = _working(tmp_path)
|
||||
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
|
||||
files = (path, replan._curves_path(path), replan._nodes_path(path))
|
||||
before = {file: file.read_bytes() for file in files}
|
||||
request = replan.RouteReplanRequest(
|
||||
vertices=[{"x": 0, "y": 0}, {"x": 320, "y": 40}, {"x": 300, "y": 300}],
|
||||
station_snap=True,
|
||||
)
|
||||
response = asyncio.run(replan.replan_route(uuid4(), request))
|
||||
assert response.status_code == 409
|
||||
assert {file: file.read_bytes() for file in files} == before
|
||||
|
||||
|
||||
def test_GET_계획노선이_스냅_값을_돌려줌(tmp_path: Path, monkeypatch) -> None:
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
|
||||
replan = _patch_replan(monkeypatch, tmp_path)
|
||||
path = _working(tmp_path)
|
||||
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
|
||||
assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is False
|
||||
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True)
|
||||
assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is True
|
||||
|
||||
|
||||
def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> None:
|
||||
from B05_Profile import B05_Profile_Engine_Sections as sections
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
|
||||
path = _working(tmp_path)
|
||||
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
|
||||
replan._curves_path(path).write_text(
|
||||
json.dumps({"curves": _CURVES, "station_snap": True}), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(sections, "_load_route_polyline", lambda *_args: _LINE)
|
||||
monkeypatch.setattr(sections, "build_surface_sampler", lambda *_args, **_kw: _Flat())
|
||||
# [저장] 때 구조물 측점 셈 — 631 은 스냅한 격자 자리
|
||||
stations, _ = sections.sample_structure_sections(
|
||||
tmp_path, "route.geojson", "f", "m", False, extra_stations=((631.0, "관"),)
|
||||
)
|
||||
assert [s["chainage_m"] for s in stations] == [631.0] and stations[0]["grid"] is True
|
||||
|
||||
seen: dict = {}
|
||||
|
||||
def stop(*_args, **kwargs):
|
||||
seen.update(kwargs)
|
||||
raise RuntimeError("여기까지")
|
||||
|
||||
monkeypatch.setattr(sections, "generate_sections", stop)
|
||||
monkeypatch.setattr(sections, "_load_pipe_points", lambda *_args: [])
|
||||
monkeypatch.setattr(sections, "resolve_extra_stations", lambda *_args: ())
|
||||
with pytest.raises(RuntimeError):
|
||||
sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False)
|
||||
assert seen["station_snap"] is True
|
||||
|
||||
|
||||
def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None:
|
||||
from B05_Profile import B05_Profile_Router_Terrain as terrain
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
|
||||
monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat())
|
||||
built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False)
|
||||
points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64))
|
||||
spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves])
|
||||
assert len(spans) == 1
|
||||
target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5)
|
||||
assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음
|
||||
|
||||
def ask(snap: bool) -> float:
|
||||
request = terrain.CrossPreviewRequest(
|
||||
vertices=[{"x": x, "y": y} for x, y in _NODES],
|
||||
chainage_m=target,
|
||||
min_radius_m=40.0,
|
||||
station_interval_m=20.0,
|
||||
station_snap=snap,
|
||||
)
|
||||
params = {"source_filter": "f", "method": "m", "smooth": False}
|
||||
return terrain._cross_preview(tmp_path, params, request)["chainage_m"]
|
||||
|
||||
assert ask(True) == target
|
||||
assert ask(False) % 20 == 0
|
||||
@@ -0,0 +1,156 @@
|
||||
"""추천 노선 큰 R(PLAN 328 · 요구 224-4) — 1단 R 맞춤 · 2단 허용 이탈 안 가장 큰 R.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_224_4_recommend_radius.py -q
|
||||
|
||||
· 네 갈래(영선 · 토공 최소 · 기울기 최소 · 비중) R ≥ 최소(모자라면 위반) · 하나 이상 큼.
|
||||
· 위반 ≤ 같은 노드로 짠 최소 R 판(노드마다 곡선 · 옛 규칙) — 묶음의 잰 노선 전부.
|
||||
· 예상노선 추적은 전처리 초기값 그대로.
|
||||
· 2단 = 탐색 경로 이탈 ≤ 허용(4 m) · 1 m 더 키우면 넘음 · [확인] 재구성 뒤 같은 R.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from shapely.geometry import LineString, Point
|
||||
|
||||
import B05_Profile.B05_Profile_Engine_RouteInitial as engine
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M
|
||||
|
||||
GRADE = 0.14
|
||||
RADIUS = 12.0
|
||||
SHOWN = ["gradeline", "earthwork", "gentle", "weighted"]
|
||||
|
||||
|
||||
def _line() -> list[tuple[float, float]]:
|
||||
return [(float(x), 200.0) for x in np.arange(50.0, 752.0, 4.0)]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def runs() -> dict:
|
||||
"""가운데 언덕(40 m) — 곧은 예상노선이 꼭대기를 넘어 갈래마다 돌아감. 묶음 · 잼도 붙잡음."""
|
||||
x = np.arange(0.0, 802.0, 2.0)
|
||||
y = np.arange(0.0, 402.0, 2.0)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2)
|
||||
terrain = Terrain(x, y, z, np.ones(xx.shape, dtype=bool))
|
||||
caught: dict = {}
|
||||
real = engine.build_pool
|
||||
|
||||
def spy(graph, follow, max_grade, min_radius_m, measure):
|
||||
caught["measure"] = measure
|
||||
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure)
|
||||
return caught["pool"]
|
||||
|
||||
engine._POOLS.clear()
|
||||
engine.build_pool = spy
|
||||
try:
|
||||
results = {
|
||||
mode: generate_initial_route(
|
||||
_line(), terrain, [], mode=mode, weights=None, max_grade=GRADE, min_radius_m=RADIUS
|
||||
)
|
||||
for mode in [*SHOWN, "follow"]
|
||||
}
|
||||
finally:
|
||||
engine.build_pool = real
|
||||
engine._POOLS.clear()
|
||||
return {"results": results, **caught}
|
||||
|
||||
|
||||
def test_shown_branches_radius_at_least_min_and_some_larger(runs: dict) -> None:
|
||||
radii = []
|
||||
for mode in SHOWN:
|
||||
for curve in runs["results"][mode]["curves"]:
|
||||
assert curve["radius_m"] >= RADIUS - 1e-6 or curve["violations"], mode
|
||||
radii.append(curve["radius_m"])
|
||||
assert max(radii) > RADIUS + 1.0
|
||||
|
||||
|
||||
def test_violations_not_above_min_radius_version(runs: dict) -> None:
|
||||
measure = runs["measure"]
|
||||
for (nodes, kw), (_, metrics) in runs["pool"].traced.items():
|
||||
floor = build_planned_polyline(
|
||||
list(nodes), min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(nodes)
|
||||
)
|
||||
least = measure(floor, **dict(kw))["over_limit"]["total"]
|
||||
assert metrics["over_limit"]["total"] <= least
|
||||
|
||||
|
||||
def test_follow_unchanged(runs: dict) -> None:
|
||||
initial = build_planned_polyline(_line(), min_radius_m=RADIUS)
|
||||
result = runs["results"]["follow"]
|
||||
assert result["planned"] == [[round(x, 4), round(y, 4)] for x, y in initial.vertices]
|
||||
assert result["curves"] == [curve.as_dict() for curve in initial.curves]
|
||||
|
||||
|
||||
def _bend(radius: float) -> list[tuple[float, float]]:
|
||||
"""직선 100 m → 반지름 `radius` 원호 90° → 직선 100 m 를 2 m 간격 점으로."""
|
||||
points = [(float(x), 0.0) for x in np.arange(-100.0, 0.0, 2.0)]
|
||||
steps = int(radius * math.pi / 2 / 2.0)
|
||||
points += [
|
||||
(radius * math.sin(a), radius - radius * math.cos(a))
|
||||
for a in np.linspace(0.0, math.pi / 2, steps)
|
||||
]
|
||||
points += [(radius, radius + float(y)) for y in np.arange(2.0, 102.0, 2.0)]
|
||||
return points
|
||||
|
||||
|
||||
def _window_gap(path, outline, curve) -> float:
|
||||
"""곡선 앞뒤 직선 가운데점 사이 경로 점이 노선에서 가장 먼 거리."""
|
||||
line = LineString(path)
|
||||
drawn = LineString(outline.vertices)
|
||||
apex = curve.apex
|
||||
nodes = [(node.x, node.y) for node in outline.nodes]
|
||||
at = nodes.index(min(nodes, key=lambda n: math.dist(n, apex)))
|
||||
ends = [
|
||||
line.project(Point((apex[0] + n[0]) / 2, (apex[1] + n[1]) / 2))
|
||||
for n in (nodes[at - 1], nodes[at + 1])
|
||||
]
|
||||
return max(
|
||||
drawn.distance(Point(p)) for p in path if min(ends) <= line.project(Point(p)) <= max(ends)
|
||||
)
|
||||
|
||||
|
||||
def test_widen_largest_radius_within_gap() -> None:
|
||||
path = _bend(80.0)
|
||||
fitted = build_planned_polyline(path, min_radius_m=RADIUS)
|
||||
wide = widen(fitted, path, RADIUS)
|
||||
assert len(wide.curves) == 1
|
||||
curve = wide.curves[0]
|
||||
assert curve.radius_m >= fitted.curves[0].radius_m
|
||||
assert curve.radius_m == math.floor(curve.radius_m) # 1 m 내림
|
||||
assert curve.radius_m > 40.0
|
||||
assert _window_gap(path, wide, curve) <= ROUTE_RECOMMEND_MAX_GAP_M + 1e-6
|
||||
# 1 m 더 키우면 허용 이탈을 넘음(가장 큰 값) — 셈 자(shapely)로 따로 잼
|
||||
points, flags, radii = _flat(wide)
|
||||
seat = flags.index(True)
|
||||
radii[seat] = curve.radius_m + 1.0
|
||||
bigger = build_planned_polyline(
|
||||
points, min_radius_m=RADIUS, simplify=False, curve_flags=flags, radii=radii
|
||||
)
|
||||
assert _window_gap(path, bigger, bigger.curves[0]) > ROUTE_RECOMMEND_MAX_GAP_M
|
||||
|
||||
|
||||
def test_widen_survives_confirm_rebuild() -> None:
|
||||
"""화면이 펴서 보내는 꼴(교각점 · 곡선 켬 · R)로 [확인]이 다시 짜도 같은 R."""
|
||||
path = _bend(60.0) + [(60.0 + float(x), 160.0) for x in np.arange(2.0, 120.0, 2.0)]
|
||||
wide = widen(build_planned_polyline(path, min_radius_m=RADIUS), path, RADIUS)
|
||||
points, flags, radii = _flat(wide)
|
||||
again = build_planned_polyline(
|
||||
points, min_radius_m=RADIUS, simplify=False, curve_flags=flags, radii=radii
|
||||
)
|
||||
assert [c.radius_m for c in again.curves] == [c.radius_m for c in wide.curves]
|
||||
assert again.vertices == wide.vertices
|
||||
|
||||
|
||||
def test_widen_keeps_fitted_when_gap_already_over() -> None:
|
||||
"""허용 이탈 0 이면 키울 R 이 없어 맞춘 R 그대로."""
|
||||
path = _bend(80.0)
|
||||
fitted = build_planned_polyline(path, min_radius_m=RADIUS)
|
||||
same = widen(fitted, path, RADIUS, max_gap_m=0.0)
|
||||
assert [round(c.radius_m, 2) for c in same.curves] == [
|
||||
round(c.radius_m, 2) for c in fitted.curves
|
||||
]
|
||||
@@ -3,6 +3,7 @@
|
||||
- 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 ·
|
||||
고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 ·
|
||||
본 화면 글 도우미
|
||||
- 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴
|
||||
- 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 ·
|
||||
숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰
|
||||
"""
|
||||
@@ -15,7 +16,7 @@ from pathlib import Path
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
|
||||
NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View")
|
||||
NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View", "Hopyo_Sheet")
|
||||
|
||||
|
||||
def _text(name: str) -> str:
|
||||
@@ -68,7 +69,15 @@ def test_고르기_창_실무_호표_줄과_고름():
|
||||
"name": "임도표지석 910×600×150",
|
||||
"sub": "북부청.xls · 제3호표 · 마스터",
|
||||
"unit": "개소 · 맞춤 2/4",
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "hopyo",
|
||||
"key": "system||HP-000001|엑셀",
|
||||
"tag": "실무 호표",
|
||||
"name": "엑셀로 보기 · 여러 호표",
|
||||
"sub": "북부청.xls · 마스터",
|
||||
"unit": "호표 1",
|
||||
},
|
||||
]
|
||||
build = got["calls"][1]
|
||||
assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층
|
||||
@@ -145,3 +154,37 @@ def test_로직_추가_한_곳과_파일_규칙():
|
||||
css = _text("M02_MasterTemplete_Hopyo.css")
|
||||
assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css)
|
||||
assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
|
||||
|
||||
def test_엑셀_보기_창_행과_호표():
|
||||
got = _run()
|
||||
assert got["calls"][2] == {
|
||||
"url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7",
|
||||
"method": "GET",
|
||||
"body": None,
|
||||
}
|
||||
sheet = got["sheet"]
|
||||
assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음
|
||||
assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O)
|
||||
assert sheet["last"] == ["AA", "A"]
|
||||
assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체
|
||||
|
||||
|
||||
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
|
||||
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
|
||||
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동
|
||||
view = _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
assert lazy in view and '"엑셀로 보기"' in view and '"엑셀에서 보기"' in view
|
||||
assert "호표: no" in view # 호표 화면 = 그 호표 고른 채
|
||||
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
|
||||
assert lazy in pick and "lib.pick(got.참조" in pick
|
||||
static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"'
|
||||
for text in (side, view, pick):
|
||||
assert not re.search(static, text, re.M) # 정적 import 없음(타입만)
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet
|
||||
assert not re.search(
|
||||
r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M
|
||||
)
|
||||
assert "readOnly: true" in sheet and "wide: true" in sheet
|
||||
|
||||
Reference in New Issue
Block a user