From 7bae5ebb7d34231efdaf43a1abdfd6cf06179ca8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:25:45 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(=EC=8A=A4=ED=94=84=EB=A0=88=EB=93=9C?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8):=20329-2=20=EC=86=90=EC=9E=A1=EC=9D=B4=20se?= =?UTF-8?q?lect(=EB=B2=94=EC=9C=84,=20=EC=8B=9C=ED=8A=B8=3F)=20=E2=80=94?= =?UTF-8?q?=20=EB=B2=94=EC=9C=84=20=EA=B8=80=20=E2=86=92=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84(=EB=B3=91=ED=95=A9=20=EB=8A=98=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EC=97=AC=EB=9F=BF)=20=C2=B7=20=EA=B7=B8=20=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=20=ED=83=AD=EC=9C=BC=EB=A1=9C=20=C2=B7=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EA=B2=8C=20=EA=B5=B4=EB=A6=BC=20=C2=B7=20=ED=8B=80=EB=A6=B0=20?= =?UTF-8?q?=EA=B8=80=20false?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- A00_Common/spreadsheet/spreadsheet.ts | 17 ++++++++++++++++- A00_Common/spreadsheet/spreadsheet_selection.ts | 14 ++++++++++++++ .../tester/spreadsheet/helper_spreadsheet_d.cjs | 5 +++++ .../spreadsheet/test_spreadsheet_d_input.py | 5 +++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 27ac5da6a..6d7fefd78 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -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; + /** 범위 고름 · 보이게 굴림(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(); diff --git a/A00_Common/spreadsheet/spreadsheet_selection.ts b/A00_Common/spreadsheet/spreadsheet_selection.ts index 46c0c3e8c..750318511 100644 --- a/A00_Common/spreadsheet/spreadsheet_selection.ts +++ b/A00_Common/spreadsheet/spreadsheet_selection.ts @@ -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); diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs index e93264998..792c7c4a1 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs @@ -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 }), diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py index ebd4f575b..4f8252708 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_d_input.py +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -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] == [ From 4c6658cf1c08dd761776890a5a42017172da17d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:32:36 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(B05):=20326-1=C2=B7326-2=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=8A=A4=EB=83=85=20=EC=84=9C=EB=B2=84=20=E2=80=94?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EB=AA=A8=EB=8D=B8=20Schema=20=EB=A1=9C?= =?UTF-8?q?=20=EC=AA=BC=EA=B0=AC=20=C2=B7=20=ED=98=B8=20=EC=95=88=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=ED=95=98=EB=82=98=EB=A5=BC=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=A4=91=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B8=B0=EB=8A=94=20=EC=A7=9D=20=EC=85=88(snap=5Fcurve=5Fstati?= =?UTF-8?q?ons=20=E2=86=94=20snapCurveStations)=20=C2=B7=20[=ED=99=95?= =?UTF-8?q?=EC=9D=B8]=20=EB=95=8C=20=EA=B3=A1=EC=84=A0=ED=91=9C=20station?= =?UTF-8?q?=5Fsnap=20=EC=A0=80=EC=9E=A5=20=C2=B7=20GET=20=EB=8F=8C?= =?UTF-8?q?=EB=A0=A4=EC=A4=8C=20=C2=B7=20=EC=8B=A4=ED=8C=A8=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=BC=EC=97=90=20=EA=B3=A1=EC=84=A0=ED=91=9C=C2=B7?= =?UTF-8?q?=EB=85=B8=EB=93=9C=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EC=85=88=20?= =?UTF-8?q?=EB=91=98=20=C2=B7=20=ED=9A=A1=EB=8B=A8=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- B05_Profile/B05_Profile_Engine_Sections.py | 25 +- .../B05_Profile_Engine_Sections_Core.py | 42 +- B05_Profile/B05_Profile_Router_Replan.py | 114 ++--- B05_Profile/B05_Profile_Router_Terrain.py | 11 +- B05_Profile/B05_Profile_Schema.py | 75 +++- B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts | 99 +++++ resources/tester/test_224_2_station_snap.py | 388 ++++++++++++++++++ 7 files changed, 662 insertions(+), 92 deletions(-) create mode 100644 resources/tester/test_224_2_station_snap.py diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index b9e354689..5dc47a85a 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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: diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 2505e8535..22e567aaa 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -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 diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 10e2f4559..67c62b68f 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -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, diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 559d64724..197578e4b 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -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 diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 0578c7334..5221c9d7d 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -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"` 으로 남아 측점 셈이 읽음.""" diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts index f7e1f4b56..67bc9aeb8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts @@ -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 | null; + apex?: ReadonlyArray | null; + end?: ReadonlyArray | null; + radius_m?: number | null; +} + +/** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */ +export function stationsAlong( + line: ReadonlyArray, + 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] { + 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, + curves: ReadonlyArray, +): 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, + spans: ReadonlyArray, +): 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); +} diff --git a/resources/tester/test_224_2_station_snap.py b/resources/tester/test_224_2_station_snap.py new file mode 100644 index 000000000..2e123b818 --- /dev/null +++ b/resources/tester/test_224_2_station_snap.py @@ -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 From 6ae8bb4f6ae19dedd72fc1861b623ede6d293177 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:32:45 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(B05=EC=B6=94=EC=B2=9C):=20328-1=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=85=B8=EC=84=A0=20R=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=E2=80=94=20=ED=83=90=EC=83=89=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99(=EB=8B=A8=EC=88=9C=ED=99=94=20=C2=B7=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20R=20=EB=A7=9E=EC=B6=A4)=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EC=B5=9C=EC=86=8C=20R=20=ED=8C=90=EB=B3=B4?= =?UTF-8?q?=EB=8B=A4=20=EC=9C=84=EB=B0=98=20=EB=A7=8E=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=20R=20=ED=8C=90=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EC=9B=80=EB=A7=90=20=EC=B6=94=EC=A0=81=20=E3=80=8C=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=20=EA=B3=A1=EC=84=A0=EB=B0=98=EC=A7=80=EB=A6=84?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../B05_Profile_Engine_RouteInitial_Pool.py | 22 ++++++++++++++++--- ...B05_Profile_UI_RouteEdit_Recommend_Help.ts | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index f09392ef6..9a7571509 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -43,6 +43,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( 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 +71,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 +106,27 @@ def _trace( traced: dict[tuple, Route], **kw: Any, ) -> Route: - # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선. + # 큰 R(PLAN 328) — 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 = (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] diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts index c3c713495..d088f80b7 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts @@ -29,7 +29,7 @@ const LOGIC_HELP: Record = { follow: { summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선", how: [ - "새로 길을 찾지 않음 — 예상노선 꺾임점에 최소 곡선반지름으로 곡선만 넣음", + "새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음", "전처리 초기 계획노선과 같은 규칙이라 같은 선이 나옴", "종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음", ], From 394fb7ba604e064e05566659571aa34c58f761d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:32:56 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(B05=EC=B6=94=EC=B2=9C):=20328-2=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=85=B8=EC=84=A0=20=ED=81=B0=20R=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=A1=EC=84=A0=EB=A7=88=EB=8B=A4=20[=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=20R,=20=EC=A0=91=EC=84=A0=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=C3=B7=20tan(IA/2)]=20=EC=95=88=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=9D=B4=ED=83=88=20=E2=89=A4=204=20m=20?= =?UTF-8?q?=EC=9D=B8=20=EA=B0=80=EC=9E=A5=20=ED=81=B0=20R(1=20m=20?= =?UTF-8?q?=EB=82=B4=EB=A6=BC)=20=C2=B7=20=EB=AA=BB=EB=B0=95=EC=95=84=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=A7=AC(=ED=8E=B8=20=EB=85=B8=EB=93=9C?= =?UTF-8?q?=20=C2=B7=20[=ED=99=95=EC=9D=B8]=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EC=84=A0)=20=C2=B7=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9=20=EC=9D=B4=ED=83=88=20=EC=83=81=EC=88=98=20?= =?UTF-8?q?=C2=B7=20test=5F224=5F4=5Frecommend=5Fradius?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../B05_Profile_Engine_RouteInitial_Pool.py | 9 +- .../B05_Profile_Engine_RouteInitial_Radius.py | 110 ++++++++++++ config/config_system_design.py | 2 + .../tester/test_224_4_recommend_radius.py | 156 ++++++++++++++++++ 4 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py create mode 100644 resources/tester/test_224_4_recommend_radius.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index 9a7571509..1f7e2d3df 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -37,6 +37,7 @@ 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, @@ -106,9 +107,9 @@ def _trace( traced: dict[tuple, Route], **kw: Any, ) -> Route: - # 큰 R(PLAN 328) — 1단 판(탐색 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤)이 위반이 - # 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않으면 그 판. 둘 다 화면이 [확인] 때 같은 선으로 - # 다시 짜는 꼴. + # 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 1단 판(탐색 경로를 + # 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 · 옛 규칙) + # 보다 많지 않은 첫 판. 셋 다 화면이 [확인] 때 같은 선으로 다시 짜는 꼴. # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). key = (tuple(nodes), tuple(sorted(kw.items()))) if key not in traced: @@ -118,7 +119,7 @@ def _trace( least = (floor, measure(floor, **kw)) path = densify_route(nodes, TRACE_DENSIFY_M) fitted = build_planned_polyline(path, min_radius_m=min_radius_m) - larger = (fitted,) + larger = (widen(fitted, path, min_radius_m), fitted) traced[key] = next( ( route diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py new file mode 100644 index 000000000..f425eaa70 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py @@ -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 + ) diff --git a/config/config_system_design.py b/config/config_system_design.py index 13fb522ef..4e7c43d93 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -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") diff --git a/resources/tester/test_224_4_recommend_radius.py b/resources/tester/test_224_4_recommend_radius.py new file mode 100644 index 000000000..befa3dae8 --- /dev/null +++ b/resources/tester/test_224_4_recommend_radius.py @@ -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 + ] From 8e96880880daa243858fd1cb5bc05081b4076cd6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:33:28 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(CAD):=20331-1=20=EA=B0=81=EB=8F=84=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20`<=EA=B0=81=EB=8F=84`=20=E2=86=92=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=C2=B7=20=EC=8B=9C=ED=80=80=EC=8A=A4=20?= =?UTF-8?q?=EC=B2=AB=20=EC=A0=90=20=EC=88=AB=EC=9E=90=20=EB=AC=B4=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 input-angle-lock — 다음 한 점만 고정 방향(동쪽 0° 반시계) · 커서 · 클릭은 고정 직선 위로 · 숫자는 그 방향 길이 · 한 점 · Esc · 새 명령이면 풂 · 커서 옆 안내 「각도 30° 고정 — 길이 입력」 - input-patterns — 숫자 음수 · .5 허용 · 각도 고정 꼴 · 점 글 해석 한 곳(parsePointText) - Enter 해석 차례 — 자유 글 단계만 원문 먼저 → 각도 고정 → 점 · 거리 → 단계 선택지 → 명령 이름 - 선 · 시퀀스 미리보기도 고정 방향 · 시퀀스 첫 점의 거리 · 상대 좌표는 무시(도구 멈춤 · 「Error in tool actor」 없앰) - 시험 resources/tester/cad/keyboard-chain.test.ts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../openwebcad/src/commands/run-command.ts | 2 + .../src/helpers/get-point-from-event.ts | 10 +- .../src/inputController/input-angle-lock.ts | 31 ++++ .../src/inputController/input-controller.ts | 155 ++++++------------ .../src/inputController/input-patterns.ts | 58 ++++++- .../src/tools/factories/sequence-tool.ts | 10 +- .../openwebcad/src/tools/line-tool.ts | 2 +- resources/tester/cad/keyboard-chain.test.ts | 150 +++++++++++++++++ 8 files changed, 299 insertions(+), 119 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts create mode 100644 resources/tester/cad/keyboard-chain.test.ts diff --git a/B07_DesignDetail/openwebcad/src/commands/run-command.ts b/B07_DesignDetail/openwebcad/src/commands/run-command.ts index 3c905bb6b..0e405565e 100644 --- a/B07_DesignDetail/openwebcad/src/commands/run-command.ts +++ b/B07_DesignDetail/openwebcad/src/commands/run-command.ts @@ -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)); diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts index 76565a4e2..90a6693bf 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts @@ -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, diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts b/B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts new file mode 100644 index 000000000..bee46aac9 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts @@ -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); +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index b3a2e3e5b..768d6fec9 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -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 + getSnapPointOnAngleGuide()?.point || + getSnapPoint()?.point || + getScreenCanvasDrawController().getWorldMouseLocation() + ); + } + + /** 점 하나를 도구에 보냄 — 각도 고정은 이 한 점으로 끝 */ + private sendPointEvent(event: PointTextEvent | MouseClickEvent) { + getActiveToolActor()?.send(event); + setAngleLock(null); + } + public handleUndo(evt: KeyboardEvent) { evt.preventDefault(); undo(); diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts b/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts index d2483f459..0d92a49c9 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts @@ -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 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; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts index c4c7859b6..78596b9e8 100644 --- a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts @@ -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 }; } diff --git a/B07_DesignDetail/openwebcad/src/tools/line-tool.ts b/B07_DesignDetail/openwebcad/src/tools/line-tool.ts index 42dc7dd3d..4ea1cb27a 100644 --- a/B07_DesignDetail/openwebcad/src/tools/line-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/line-tool.ts @@ -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(); diff --git a/resources/tester/cad/keyboard-chain.test.ts b/resources/tester/cad/keyboard-chain.test.ts new file mode 100644 index 000000000..be66f34d4 --- /dev/null +++ b/resources/tester/cad/keyboard-chain.test.ts @@ -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); + }); +}); From e36fc5091d04120dc0e57e070a8a4da01aa321b0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:33:32 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20329-3=20=EC=97=91=EC=85=80=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B0=BD=20=E2=80=94=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=8B=9C=ED=8A=B8(=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=ED=83=AD)=20+=20=ED=98=B8=ED=91=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D(=EC=B2=B4=ED=81=AC=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=A7=9E=EC=B6=A4)=20=C2=B7=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=20=E2=86=92=20=EC=98=81=EC=97=AD=20=EA=B0=95?= =?UTF-8?q?=EC=A1=B0=20=C2=B7=20=EC=B9=B8=20=EB=88=84=EB=A6=84=20=E2=86=92?= =?UTF-8?q?=20=EA=B7=B8=20=ED=98=B8=ED=91=9C=20=C2=B7=20=EA=B3=A0=EB=A5=B8?= =?UTF-8?q?=20=ED=98=B8=ED=91=9C=20=ED=95=9C=20=EB=B2=88=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9=20+=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=ED=91=9C=20=C2=B7=20=EC=97=AC=EB=8A=94=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=20=EB=84=B7(=EC=98=AC=EB=A6=B0=20=EC=A7=81=ED=9B=84=20=C2=B7?= =?UTF-8?q?=20=EC=97=91=EC=85=80=EB=A1=9C=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=97=91=EC=85=80=EC=97=90=EC=84=9C=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=8D=A7?= =?UTF-8?q?=EC=A4=84)=20=C2=B7=20=EB=8A=A6=EA=B2=8C=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- .../M02_MasterTemplete_Hopyo.css | 78 +++++ .../M02_MasterTemplete_Hopyo_Api.ts | 21 ++ .../M02_MasterTemplete_Hopyo_Pick.ts | 40 ++- .../M02_MasterTemplete_Hopyo_Sheet.ts | 282 ++++++++++++++++++ .../M02_MasterTemplete_Hopyo_Side.ts | 5 +- .../M02_MasterTemplete_Hopyo_View.ts | 25 +- resources/tester/helper_m02_hopyo_pick.cjs | 18 +- resources/tester/test_m02_hopyo_screen.py | 47 ++- 8 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 7a00414cc..134403225 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -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; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index e11e7dcec..c4ef47641 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -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 => export const readHopyo = (scope: HopyoScope, key: string): Promise => call(at(scope, `/hopyo/${enc(key)}`)); +/** 격자 — 없으면 404 「격자 없음 — 다시 올리면 보임」(옛 파일) */ +export const readHopyoGrid = (scope: HopyoScope, key: string): Promise => + call(at(scope, `/hopyo-grid/${enc(key)}`)); + export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise => call(at(scope, `/hopyo/${enc(key)}/${no}`)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index 04cdd4c89..c460ce6a3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -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(); + 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), + }), + ), }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts new file mode 100644 index 000000000..e547de48e --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -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): 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(); + const items = new Map(); + + 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 { + 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 { + 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 { + 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(); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index 74ce15703..3bd583efb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -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)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 5601885cf..3aa5fe2c8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -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, ], }), diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index a061c282c..1a389f69f 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -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) => { diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 46345114b..804e09399 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -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