diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index cc0b3335e..8145a6ba3 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -15,13 +15,10 @@ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drain import { readState, writeState } from "../b_page_state"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; -import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; -import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; import type { StructureInstance, StructureType, } from "../../B05_Profile/B05_Profile_Api_Structures"; -import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay"; import { hasLegacyAlignment, readAlignment, @@ -56,11 +53,7 @@ import { structureIdKeeping, } from "../../B05_Profile/B05_Profile_UI_Profile_Structures"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; -import { - createRouteMassHaulPanel, - type MassHaulSummaryValues, - type RouteMassHaulContext, -} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul"; +import type { RouteMassHaulContext } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul"; import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance"; import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights"; import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render"; @@ -83,9 +76,11 @@ import { snapToMasterStations, } from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; -import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; -import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; +import type { ElevationWindowResult } from "@util/common_util_chart_ywindow"; import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots"; +import { noticeDroppedEdits } from "./design_save"; +import { createProfileOverlays } from "./design_profile_overlays"; +import { bindProfileViewport } from "./design_profile_viewport"; import "../../B06_Section/B06_Section_UI_Style.css"; // SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 // 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. @@ -161,40 +156,14 @@ export function createDesignProfile(ctx: DesignProfileContext) { if (drag.isSubDragging()) scheduleLightSync(); else draw(); }; - /** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). - * 유토곡선을 접어도 이 값은 남아야 한다. */ - const massBadge = createMassHaulBadge(); - // 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다. - massBadge.root.style.setProperty("--mass-haul-badge-top", "34px"); - bodyWrap.append(massBadge.root); - let massHaulSummary: MassHaulSummaryValues | null = null; - // 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면 - // 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다. - const haulPrefetch = createHaulPlanPrefetch( + // 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계). + const { massHaul, tableOverlay } = createProfileOverlays({ projectId, - () => routeId ?? undefined, - subPanelChanged, - ); - const massHaul = createRouteMassHaulPanel( - subPanelChanged, - (summary) => { - const before = massHaulSummary; - massHaulSummary = summary; - if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary); - }, - haulPrefetch, - ); - bodyWrap.append(massHaul.overlay, massHaul.handle); - // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. - massHaul.attachScrollSync(body); - // 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시). - const tableOverlay = createProfileTableOverlay(subPanelChanged); - bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); - tableOverlay.attachScrollSync(body); - // 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시). - // 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다. - body.addEventListener("contextmenu", (event) => event.preventDefault()); - massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault()); + routeId: () => routeId, + bodyWrap, + body, + onChange: subPanelChanged, + }); const content = document.createElement("div"); content.className = "b05-route-profile__content"; /** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */ @@ -453,7 +422,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { zoom: profileZoom.state, holdElevationRange: elevationHold.hold, toolActive: () => tools.mode() !== "none", - selectedRuns: () => tools.selectedRuns(), + tools, stationIdAtStructure, redraw: draw, setElevationWindowUpdater: (update) => { @@ -462,44 +431,17 @@ export function createDesignProfile(ctx: DesignProfileContext) { }); } - /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ - const SCROLL_SETTLE_MS = 80; - /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ - let settledScrollLeft = 0; - let scrollSettleTimer = 0; - // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 - // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 - // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. - body.addEventListener("scroll", () => { - if (elevationHold.editing()) return; - const fitted = updateElevationWindow?.() ?? null; - window.clearTimeout(scrollSettleTimer); - if (!needsFullRedraw(fitted)) return; - scrollSettleTimer = window.setTimeout(() => { - if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; - settledScrollLeft = body.scrollLeft; - draw(); - }, SCROLL_SETTLE_MS); + // 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계). + const unbindViewport = bindProfileViewport({ + body, + editing: () => elevationHold.editing(), + fitElevation: () => updateElevationWindow?.() ?? null, + draw: () => draw(), + lastSize: () => ({ width: lastWidth, height: lastHeight }), + scheduleLightSync, + scheduleDraw: () => drag.scheduleDraw(), }); - // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 - // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). - attachWheelHorizontalScroll(body); - - const resizeObserver = new ResizeObserver(() => { - if ( - body.clientWidth <= 0 || - body.clientHeight <= 0 || - (Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1) - ) - return; - // 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번. - // (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고) - scheduleLightSync(); - drag.scheduleDraw(); - }); - resizeObserver.observe(body); - // 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫). bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw)); @@ -650,6 +592,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { async save(): Promise { if (!routeId || !store.dirty()) return; const saved = await saveProfileAlignment(projectId, routeId, store.edits()); + noticeDroppedEdits(saved); const next = saved.profile_alignment as ProfileAlignment | undefined; if (next?.base_pvi?.length) { base = toAlignmentBase(next); @@ -689,7 +632,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { setListening(false); drag.dispose(); crossPreview.dispose(); - resizeObserver.disconnect(); + unbindViewport(); }, }; } diff --git a/A00_Common/design/design_profile_free.ts b/A00_Common/design/design_profile_free.ts new file mode 100644 index 000000000..9a35981fb --- /dev/null +++ b/A00_Common/design/design_profile_free.ts @@ -0,0 +1,363 @@ +/* ============================================================================= + * A00_Common/design/design_profile_free.ts + * 종단 [자유지정] 계산 — 범위 · 클릭 점 · 측점선 붙음 · 높이 변환 · 기울기 판정 · 적용(215-1). + * + * 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은 + * 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽 + * 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기. + * + * 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고 + * [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L + * (R = L / |대수차|). 양 끝 세로선의 라운드는 직선화처럼 그대로 둔다. + * + * 화면(`design_profile_free_view.ts`)은 클릭 · 마우스 · 키만 넘기고 여기 표시 자료를 그린다. + * ========================================================================== */ + +import type { + AlignmentBase, + AlignmentEdits, + ProfileAlignment, +} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import { + buildAlignment, + chainageKey, + controlElevationAt, +} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import { + baseElevationAt, + defaultCurveLength, +} from "../../B05_Profile/B05_Profile_UI_Profile_Straighten"; + +/** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */ +const SAME_M = 1e-6; +/** 클릭 높이를 이 단위(m)로 끊는다. */ +const ELEVATION_STEP_M = 0.01; + +export interface FreeVertex { + chainage_m: number; + elevation_m: number; +} + +/** range = 범위 고르는 중 · armed = 시작 세로선을 기다림 · drawing = 점 찍는 중. */ +export type FreePhase = "range" | "armed" | "drawing"; + +/** 그래프 세로 창 — 그릴 때 기준(`data-yw-*`)과 지금 걸린 변환. */ +export interface ChartWindow { + center: number; + pxPerM: number; + zero: number; + scale: number; + offset: number; +} + +/** 세로 창 변환 글(`translate(0 a) scale(1 b)`)을 읽는다 — 없으면 그릴 때 그대로. */ +export function parseWindowTransform(text: string | null): { scale: number; offset: number } { + const num = "([-+0-9.eE]+)"; + const match = new RegExp(`translate\\(\\s*0[ ,]+${num}\\s*\\)\\s*scale\\(\\s*1[ ,]+${num}`).exec( + text ?? "", + ); + if (!match) return { scale: 1, offset: 0 }; + const offset = Number(match[1]); + const scale = Number(match[2]); + return Number.isFinite(offset) && Number.isFinite(scale) && scale > 0 + ? { scale, offset } + : { scale: 1, offset: 0 }; +} + +/** 그래프 y(px) → 표고(m). 과장 배율은 `pxPerM` 에 들어 있다. */ +export function elevationAtChartY(view: ChartWindow, y: number): number { + const baked = (y - view.offset) / view.scale; + return view.center + (view.zero - baked) / view.pxPerM; +} + +/** 표고(m) → 그래프 y(px). `elevationAtChartY` 의 역. */ +export function chartYAtElevation(view: ChartWindow, elevationM: number): number { + return view.scale * (view.zero - (elevationM - view.center) * view.pxPerM) + view.offset; +} + +/** 가장 가까운 측점 세로선. 범위를 주면 그 안(양 끝 포함)에서만 고른다. */ +export function snapStation( + stations: number[], + chainageM: number, + range: [number, number] | null = null, +): number | null { + let best: number | null = null; + for (const station of stations) { + if (range && (station < range[0] - SAME_M || station > range[1] + SAME_M)) continue; + if (best === null || Math.abs(station - chainageM) < Math.abs(best - chainageM)) { + best = station; + } + } + return best; +} + +/** 두 점 사이 기울기(%). */ +export function gradePct(from: FreeVertex, to: FreeVertex): number { + const span = to.chainage_m - from.chainage_m; + return Math.abs(span) > 1e-12 ? ((to.elevation_m - from.elevation_m) / span) * 100 : 0; +} + +/** 임도 기준 최대 기울기를 넘는가 — 판정은 선형 계산의 위반 판정(`violations`)과 같은 식. */ +export function overGrade(pct: number, maxPct: number): boolean { + return Math.abs(pct) > maxPct + 1e-6; +} + +/** 점들을 잇는 꺾은선 위 표고(chainage 오름차순 꺾은선). */ +function elevationOnPath(path: FreeVertex[], chainageM: number): number { + for (let index = 1; index < path.length; index += 1) { + const left = path[index - 1]; + const right = path[index]; + if (chainageM > right.chainage_m + SAME_M) continue; + const span = right.chainage_m - left.chainage_m; + const ratio = span > 1e-12 ? (chainageM - left.chainage_m) / span : 0; + return left.elevation_m + (right.elevation_m - left.elevation_m) * ratio; + } + return path[path.length - 1].elevation_m; +} + +/** + * 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를 + * 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음). + */ +export function buildFreeEdits( + base: AlignmentBase, + edits: AlignmentEdits, + vertices: FreeVertex[], +): AlignmentEdits { + const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m); + if (path.length < 2) return edits; + const fromM = path[0].chainage_m; + const toM = path[path.length - 1].chainage_m; + if (toM - fromM <= SAME_M) return edits; + const inner = new Map(); + buildAlignment(base, edits).pvi.forEach((node) => { + if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) { + inner.set(chainageKey(node.chainage_m), node.chainage_m); + } + }); + path + .slice(1, -1) + .forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m)); + const offsets = { ...edits.station_offsets }; + const radii = { ...edits.curve_radii }; + inner.forEach((chainage, key) => { + const target = elevationOnPath(path, chainage); + offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6)); + delete radii[key]; + }); + for (let index = 1; index < path.length - 1; index += 1) { + const gradeIn = gradePct(path[index - 1], path[index]) / 100; + const gradeOut = gradePct(path[index], path[index + 1]) / 100; + const deltaGrade = Math.abs(gradeOut - gradeIn); + if (deltaGrade < 1e-9) continue; + radii[chainageKey(path[index].chainage_m)] = Number( + (defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6), + ); + } + return { ...edits, station_offsets: offsets, curve_radii: radii }; +} + +export interface FreeSegment { + from: FreeVertex; + to: FreeVertex; + grade_pct: number; + over: boolean; + /** 마지막 점 → 마우스 자리(아직 안 찍음). */ + preview: boolean; +} + +/** 오버레이 표시 자료 — 화면은 이것만 그린다. */ +export interface FreeMarks { + /** 고른 범위(강조 띠) — 둘 다 고르기 전엔 null. */ + band: [number, number] | null; + /** 세로 강조선 chainage(범위 첫 측점 또는 범위 양 끝). */ + edges: number[]; + segments: FreeSegment[]; + /** 찍힌 점(시작 점 포함). */ + dots: FreeVertex[]; +} + +export interface FreeDrawDeps { + base: () => AlignmentBase | null; + alignment: () => ProfileAlignment | null; + edits: () => AlignmentEdits; + /** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */ + apply: (next: AlignmentEdits) => void; + /** 모드를 끈다(적용 뒤 · Esc). */ + close: () => void; + /** 단계 · 안내가 바뀜. */ + changed: () => void; +} + +export interface FreeDraw { + phase: () => FreePhase; + reset: () => void; + /** 그래프 클릭 — 날 chainage · 표고와 그래프 측점선 목록. 먹었으면 true. */ + click: (chainageM: number, elevationM: number, stations: number[]) => boolean; + /** 마우스 자리 — 미리 보기 대상. 그래프 밖이면 null. */ + hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void; + /** Enter · Escape · Backspace — 먹었으면 true. */ + key: (key: string) => boolean; + marks: () => FreeMarks; + hint: () => string; +} + +export function createFreeDraw(deps: FreeDrawDeps): FreeDraw { + let phase: FreePhase = "range"; + let pending: number | null = null; + let range: [number, number] | null = null; + let anchor: number | null = null; + let points: FreeVertex[] = []; + let target: FreeVertex | null = null; + + function reset(): void { + phase = "range"; + pending = null; + range = null; + anchor = null; + points = []; + target = null; + } + + /** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */ + function heightAt(chainageM: number): number | null { + const alignment = deps.alignment(); + return alignment ? controlElevationAt(alignment, chainageM) : null; + } + + const finishM = (): number | null => + range && anchor !== null ? (anchor === range[0] ? range[1] : range[0]) : null; + const same = (a: number | null, b: number | null): boolean => + a !== null && b !== null && Math.abs(a - b) < SAME_M; + + /** 지나온 점(시작 포함) — 마지막 것이 다음 선의 출발점. */ + function path(): FreeVertex[] { + if (anchor === null) return []; + const z = heightAt(anchor); + return z === null ? [] : [{ chainage_m: anchor, elevation_m: z }, ...points]; + } + + /** 클릭 · 마우스 자리 → 다음 점 후보. 끝 세로선이면 그 계획고, 뒤로 가면 null. */ + function candidate(chainageM: number, elevationM: number, stations: number[]): FreeVertex | null { + if (phase !== "drawing" || !range) return null; + const station = snapStation(stations, chainageM, range); + const end = finishM(); + if (station === null || end === null) return null; + if (same(station, end)) { + const z = heightAt(end); + return z === null ? null : { chainage_m: end, elevation_m: z }; + } + const walked = path(); + const last = walked[walked.length - 1]; + if (!last || (station - last.chainage_m) * Math.sign(end - last.chainage_m) <= SAME_M) { + return null; + } + const z = Math.round(elevationM / ELEVATION_STEP_M) * ELEVATION_STEP_M; + return { chainage_m: station, elevation_m: Number(z.toFixed(3)) }; + } + + function finish(): void { + const base = deps.base(); + const end = finishM(); + const z = end === null ? null : heightAt(end); + if (!base || end === null || z === null) return; + const vertices = [...path(), { chainage_m: end, elevation_m: z }]; + const next = buildFreeEdits(base, deps.edits(), vertices); + reset(); + deps.apply(next); + deps.close(); + } + + return { + phase: () => phase, + reset, + click(chainageM, elevationM, stations) { + if (phase === "range") { + const station = snapStation(stations, chainageM); + if (station === null) return true; + if (pending === null || same(pending, station)) pending = station; + else { + range = [Math.min(pending, station), Math.max(pending, station)]; + pending = null; + phase = "armed"; + } + } else if (phase === "armed" && range) { + const station = snapStation(stations, chainageM, range); + if (same(station, range[0]) || same(station, range[1])) { + anchor = station; + points = []; + phase = "drawing"; + } + } else if (phase === "drawing") { + const next = candidate(chainageM, elevationM, stations); + if (!next) return true; + if (same(next.chainage_m, finishM())) { + finish(); + return true; + } + points.push(next); + } + target = null; + deps.changed(); + return true; + }, + hover(pointer) { + target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null; + }, + key(key) { + if (key === "Escape") { + reset(); + deps.close(); + return true; + } + if (key === "Enter") { + if (phase !== "drawing") return false; + finish(); + return true; + } + if (key === "Backspace") { + if (phase !== "drawing") return false; + if (points.length) points.pop(); + else { + anchor = null; + phase = "armed"; + } + target = null; + deps.changed(); + return true; + } + return false; + }, + marks() { + const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY; + const dots = path(); + const line = target ? [...dots, target] : dots; + const segments: FreeSegment[] = []; + for (let index = 1; index < line.length; index += 1) { + const grade = gradePct(line[index - 1], line[index]); + segments.push({ + from: line[index - 1], + to: line[index], + grade_pct: grade, + over: overGrade(grade, maxPct), + preview: target !== null && index === line.length - 1, + }); + } + return { + band: range, + edges: range ? [...range] : pending !== null ? [pending] : [], + segments, + dots, + }; + }, + hint() { + if (phase === "range") { + return pending === null + ? "자유지정 — 범위 첫 세로 측점선을 고르세요" + : `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`; + } + if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)"; + return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)"; + }, + }; +} diff --git a/A00_Common/design/design_profile_free_view.ts b/A00_Common/design/design_profile_free_view.ts new file mode 100644 index 000000000..9584478f9 --- /dev/null +++ b/A00_Common/design/design_profile_free_view.ts @@ -0,0 +1,217 @@ +/* ============================================================================= + * A00_Common/design/design_profile_free_view.ts + * 종단 [자유지정] 화면 — 그래프 클릭 · 마우스 · 오버레이(범위 강조 · 점선 · 기울기 글)(215-1). + * + * 계산 · 상태는 `design_profile_free.ts` 몫. 여기서는 마우스 자리를 (chainage, 표고)로 바꿔 + * 넘기고 돌려받은 표시 자료를 그린다. 표고 변환은 종단 그래프 세로 창(`data-yw-*` + 지금 걸린 + * 변환)을 그대로 되짚는다 — 스크롤로 창이 바뀌어도 같은 식이다. + * + * 그래프는 그릴 때마다 새로 만들어지므로 그릴 때마다 `mount` 를 다시 부른다. + * 자유지정 중엔 그래프 클릭 · 누르기를 여기서 먹는다(측점 고르기 · 구조물 끌기로 새지 않게). + * ========================================================================== */ + +import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../../B06_Section/B06_Section_UI_Longitudinal"; +import { + chartYAtElevation, + elevationAtChartY, + parseWindowTransform, + type ChartWindow, + type FreeDraw, +} from "./design_profile_free"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** 종단 그래프 한 벌의 가로 매핑 — 렌더러가 그릴 때마다 넘긴다. */ +export interface FreeChartGeom { + /** 그래프 래퍼(`b05-profile__chart`). */ + chart: HTMLElement; + /** 가로 스크롤러 — 스크롤하면 세로 창이 바뀌어 다시 그린다. */ + scroller: HTMLElement; + x: (chainageM: number) => number; + toChainage: (px: number) => number; + widthPx: number; + heightPx: number; + /** 그래프 세로 측점선 chainage(정규 + 구조물). */ + stations: number[]; +} + +export interface FreeView { + /** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */ + mount: (geom: FreeChartGeom | null, active: boolean) => void; + redraw: () => void; +} + +function svgEl(tag: string, attrs: Record): SVGElement { + const element = document.createElementNS(SVG_NS, tag); + Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value))); + return element; +} + +/** 지금 그래프의 세로 창 — 자동 창 SVG 가 없으면 null. */ +function readWindow(chart: HTMLElement): { view: ChartWindow; top: number } | null { + const svg = chart.querySelector(`svg[${Y_WINDOW_ATTRS.auto}="1"]`); + if (!svg) return null; + const read = (name: string): number => Number(svg.getAttribute(name)); + const layer = svg.querySelector(`.${Y_WINDOW_CLASS}`); + const view: ChartWindow = { + center: read(Y_WINDOW_ATTRS.center), + pxPerM: read(Y_WINDOW_ATTRS.pxPerM), + zero: read(Y_WINDOW_ATTRS.zero), + ...parseWindowTransform(layer?.getAttribute("transform") ?? null), + }; + if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0) { + return null; + } + // 그래프 SVG 가 래퍼 위쪽에서 떨어져 있으면 그만큼 오버레이 y 를 맞춘다. + const top = svg.getBoundingClientRect().top - chart.getBoundingClientRect().top; + return { view, top }; +} + +export function createFreeView(free: FreeDraw): FreeView { + let geom: FreeChartGeom | null = null; + let active = false; + let overlay: SVGSVGElement | null = null; + let listeners: AbortController | null = null; + + /** 마우스 자리 → (chainage, 표고). 그래프 밖이면 null. */ + function pointerAt(event: MouseEvent) { + if (!geom) return null; + const win = readWindow(geom.chart); + if (!win) return null; + const rect = geom.chart.getBoundingClientRect(); + const chainageM = geom.toChainage(event.clientX - rect.left); + if (!Number.isFinite(chainageM)) return null; + const elevationM = elevationAtChartY(win.view, event.clientY - rect.top - win.top); + return { chainageM, elevationM, stations: geom.stations }; + } + + function redraw(): void { + overlay?.remove(); + overlay = null; + if (!geom || !active) return; + const win = readWindow(geom.chart); + if (!win) return; + const { x, widthPx, heightPx } = geom; + const y = (elevationM: number): number => chartYAtElevation(win.view, elevationM) + win.top; + const marks = free.marks(); + const svg = svgEl("svg", { + class: "b05-profile-freemark", + width: widthPx, + height: heightPx, + viewBox: `0 0 ${widthPx} ${heightPx}`, + }) as SVGSVGElement; + if (marks.band) { + const [left, right] = marks.band.map(x); + svg.append( + svgEl("rect", { + class: "b05-profile-freemark__band", + x: left, + y: 0, + width: Math.max(0, right - left), + height: heightPx, + }), + ); + } + marks.edges.forEach((chainage) => + svg.append( + svgEl("line", { + class: "b05-profile-freemark__edge", + x1: x(chainage), + x2: x(chainage), + y1: 0, + y2: heightPx, + }), + ), + ); + marks.segments.forEach((segment) => { + const x1 = x(segment.from.chainage_m); + const x2 = x(segment.to.chainage_m); + const y1 = y(segment.from.elevation_m); + const y2 = y(segment.to.elevation_m); + const over = segment.over ? " is-over" : ""; + svg.append( + svgEl("line", { + class: `b05-profile-freemark__line${over}${segment.preview ? " is-preview" : ""}`, + x1, + y1, + x2, + y2, + }), + ); + const label = svgEl("text", { + class: `b05-profile-freemark__grade${over}`, + x: (x1 + x2) / 2, + y: (y1 + y2) / 2 - 6, + "text-anchor": "middle", + }); + label.textContent = `${segment.grade_pct.toFixed(1)}%`; + svg.append(label); + }); + marks.dots.forEach((dot) => + svg.append( + svgEl("circle", { + class: "b05-profile-freemark__dot", + cx: x(dot.chainage_m), + cy: y(dot.elevation_m), + r: 3.5, + }), + ), + ); + overlay = svg; + geom.chart.append(svg); + } + + function mount(next: FreeChartGeom | null, on: boolean): void { + listeners?.abort(); + listeners = null; + if (next) geom = next; + active = on; + if (!on) free.reset(); + if (!geom || !on || !geom.chart.isConnected) { + overlay?.remove(); + overlay = null; + return; + } + listeners = new AbortController(); + const { signal } = listeners; + const chart = geom.chart; + const swallow = (event: Event): void => { + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + // 누르기는 삼킴 — 구조물 측점선 끌기 · 계획고 버튼 길게 누르기가 시작되지 않게. + chart.addEventListener("pointerdown", swallow, { capture: true, signal }); + chart.addEventListener( + "click", + (event) => { + swallow(event); + event.preventDefault(); + const pointer = pointerAt(event); + if (pointer) free.click(pointer.chainageM, pointer.elevationM, pointer.stations); + free.hover(pointerAt(event)); + redraw(); + }, + { capture: true, signal }, + ); + chart.addEventListener( + "mousemove", + (event) => { + free.hover(pointerAt(event)); + redraw(); + }, + { signal }, + ); + chart.addEventListener( + "mouseleave", + () => { + free.hover(null); + redraw(); + }, + { signal }, + ); + geom.scroller.addEventListener("scroll", redraw, { signal, passive: true }); + redraw(); + } + + return { mount, redraw }; +} diff --git a/A00_Common/design/design_profile_overlays.ts b/A00_Common/design/design_profile_overlays.ts new file mode 100644 index 000000000..eb60de408 --- /dev/null +++ b/A00_Common/design/design_profile_overlays.ts @@ -0,0 +1,63 @@ +/* ============================================================================= + * design_profile_overlays.ts + * 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌). + * 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫. + * ========================================================================== */ + +import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; +import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; +import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay"; +import { + createRouteMassHaulPanel, + type MassHaulSummaryValues, +} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul"; + +export interface ProfileOverlaysDeps { + projectId: string; + routeId: () => number | null; + /** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */ + bodyWrap: HTMLElement; + /** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */ + body: HTMLElement; + /** 서브패널이 바뀜(펼침 · 범례 · 리사이저 · 유토 배분 도착). */ + onChange: () => void; +} + +export function createProfileOverlays(deps: ProfileOverlaysDeps) { + const { bodyWrap, body, onChange } = deps; + /** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). + * 유토곡선을 접어도 이 값은 남아야 한다. */ + const massBadge = createMassHaulBadge(); + // 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다. + massBadge.root.style.setProperty("--mass-haul-badge-top", "34px"); + bodyWrap.append(massBadge.root); + let massHaulSummary: MassHaulSummaryValues | null = null; + // 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면 + // 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다. + const haulPrefetch = createHaulPlanPrefetch( + deps.projectId, + () => deps.routeId() ?? undefined, + onChange, + ); + const massHaul = createRouteMassHaulPanel( + onChange, + (summary) => { + const before = massHaulSummary; + massHaulSummary = summary; + if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary); + }, + haulPrefetch, + ); + bodyWrap.append(massHaul.overlay, massHaul.handle); + // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. + massHaul.attachScrollSync(body); + // 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시). + const tableOverlay = createProfileTableOverlay(onChange); + bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); + tableOverlay.attachScrollSync(body); + // 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시). + // 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다. + body.addEventListener("contextmenu", (event) => event.preventDefault()); + massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault()); + return { massHaul, tableOverlay }; +} diff --git a/A00_Common/design/design_profile_viewport.ts b/A00_Common/design/design_profile_viewport.ts new file mode 100644 index 000000000..ea193f514 --- /dev/null +++ b/A00_Common/design/design_profile_viewport.ts @@ -0,0 +1,66 @@ +/* ============================================================================= + * design_profile_viewport.ts + * 종단 스크롤러의 가로 스크롤 · 휠 · 크기 바뀜 듣개(213-3 · 700줄 한계로 뗌). + * 다시 그리기 · 높이 동기화는 종단도가 넘긴 함수를 부른다. + * ========================================================================== */ + +import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; +import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; + +/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ +const SCROLL_SETTLE_MS = 80; + +export interface ProfileViewportDeps { + body: HTMLElement; + /** ▲▼ 계획고 편집으로 Y 축을 잡고 있는 중. */ + editing: () => boolean; + /** 세로 창 갱신기 — 그릴 때마다 렌더러가 새로 준다(없으면 null). */ + fitElevation: () => ElevationWindowResult | null; + draw: () => void; + /** 마지막으로 그린 본문 크기 — 같으면 크기 바뀜을 무시한다. */ + lastSize: () => { width: number; height: number }; + scheduleLightSync: () => void; + scheduleDraw: () => void; +} + +/** 듣개를 붙이고 떼는 함수를 돌려준다. */ +export function bindProfileViewport(deps: ProfileViewportDeps): () => void { + const { body } = deps; + /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ + let settledScrollLeft = 0; + let scrollSettleTimer = 0; + // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 + // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 + // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. + body.addEventListener("scroll", () => { + if (deps.editing()) return; + const fitted = deps.fitElevation(); + window.clearTimeout(scrollSettleTimer); + if (!needsFullRedraw(fitted)) return; + scrollSettleTimer = window.setTimeout(() => { + if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; + settledScrollLeft = body.scrollLeft; + deps.draw(); + }, SCROLL_SETTLE_MS); + }); + + // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 + // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). + attachWheelHorizontalScroll(body); + + const resizeObserver = new ResizeObserver(() => { + const last = deps.lastSize(); + if ( + body.clientWidth <= 0 || + body.clientHeight <= 0 || + (Math.abs(body.clientWidth - last.width) < 1 && Math.abs(body.clientHeight - last.height) < 1) + ) + return; + // 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번. + // (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고) + deps.scheduleLightSync(); + deps.scheduleDraw(); + }); + resizeObserver.observe(body); + return () => resizeObserver.disconnect(); +} diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 8c8bad1fa..827ffbfe9 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -65,11 +65,26 @@ function reportOnly(label: string): (error: unknown) => void { }; } +/** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */ +export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void { + const dropped = saved.dropped_edit_chainages ?? []; + if (!dropped.length) return; + const where = dropped + .slice(0, 5) + .map((value) => `${value.toFixed(2)}m`) + .join(" · "); + showToast( + `계획고 편집 ${dropped.length}곳을 지웠습니다 — 측점 · 관 · 구조물이 없는 자리: ${where}` + + (dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""), + "info", + ); +} + /** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */ async function flushAlignmentDraft(projectId: string, routeId: number): Promise { const draft = pendingAlignment(routeId); if (!draft) return; - await saveProfileAlignment(projectId, routeId, draft); + noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft)); markAlignmentSaved(routeId); } diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index de688e943..0e16185a5 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -97,6 +97,9 @@ export function resolveSelection( return { pipeChainageM: null, structureId: hit?.structure_id ?? null }; } +/** 측점 하나 고름(횡단 카드 · 종단 측점선)이 덮어 열어도 되는 군 — 노면공(G군)은 뺌(152-5 · B05 = B06 216-1). */ +export const coversAtStation = (group: string | undefined): boolean => group !== "G"; + /* ── 세션 값 + 알림 ─────────────────────────────────────────────────────── */ interface Stored { diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 4124b09a5..9a1fe7824 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -31,7 +31,13 @@ import { restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { NO_SELECTION, nearestWithin, readSelection, selectDesign } from "./design_selection"; +import { + coversAtStation, + NO_SELECTION, + nearestWithin, + readSelection, + selectDesign, +} from "./design_selection"; import type { DesignPageHost } from "./design_page"; import { bindDesignView, @@ -223,6 +229,11 @@ export async function mountProfileView( selectMarker: (id) => viewer.markers.selectStation(id), selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null), selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM), + // 측점선 · 3D 띠 고름 = 횡단 카드 고름(B06 showAtChainage)과 같은 규칙 · 같은 함수(216-1) + openAtStation: (chainageM) => + panel.structures.selectAt({ chainageM, structureId: null }, (entry) => + coversAtStation(bridge.typeGroup(entry.type_id)), + ), selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), diff --git a/A00_Common/key_shortcut.ts b/A00_Common/key_shortcut.ts new file mode 100644 index 000000000..3957f90f7 --- /dev/null +++ b/A00_Common/key_shortcut.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * key_shortcut.ts — Ctrl(⌘)+글자 단축키 판정 공용(PLAN 214-2) + * 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞음 — 영문 글자·숫자면 그 글자 · + * 아니면 자판 자리(`code`: KeyZ · Digit1)로 판정. Shift 의 대문자도 소문자로 맞춤. + * 쓰는 곳 — 페이지(B05 · M02 · 시트 키) + CAD(`B07_DesignDetail/openwebcad` 의 `input-shortcut.ts` 가 다시 내보냄). + * 시험 = `resources/tester/cad/ctrl-shortcut-hangul.test.ts` + * ========================================================================== */ + +type KeyLike = Pick & Partial>; + +/** 누른 글자·숫자(소문자) — 글자·숫자 자리가 아니면 빈 글 */ +export function keyLetter(evt: KeyLike): string { + const key = evt.key ?? ""; + if (/^[a-zA-Z0-9]$/.test(key)) return key.toLowerCase(); + const m = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(evt.code ?? ""); + return m ? (m[1] ?? m[2]).toLowerCase() : ""; +} + +/** Ctrl(Mac ⌘)과 함께 누른 `letter`(소문자 글자·숫자)인가 — Shift · Alt 는 보지 않음 */ +export function isCtrlKey( + evt: KeyLike & Pick, + letter: string, +): boolean { + return (evt.ctrlKey || evt.metaKey) && keyLetter(evt) === letter; +} + +/** Ctrl 단축키 이름 */ +export type CtrlShortcut = "undo" | "redo" | "save" | "selectAll"; + +/** 공용 Ctrl 단축키 — Z · Shift+Z · Y · S · A (Alt 함께는 아님 · Shift 는 Z 만) */ +export function ctrlShortcut( + evt: KeyLike & + Pick & + Partial>, +): CtrlShortcut | null { + if (!(evt.ctrlKey || evt.metaKey) || evt.altKey) return null; + switch (keyLetter(evt)) { + case "z": + return evt.shiftKey ? "redo" : "undo"; + case "y": + return evt.shiftKey ? null : "redo"; + case "s": + return evt.shiftKey ? null : "save"; + case "a": + return evt.shiftKey ? null : "selectAll"; + default: + return null; + } +} diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 2d94e6e8b..1d3e8b069 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -12,6 +12,7 @@ import "./random_uuid_polyfill"; import "@ui/ui_template_theme.css"; import { injectBaseStyles } from "@ui/ui_template_elements"; import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu"; +import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe"; import { ROUTES, type RoutePath } from "@config/config_frontend"; import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell"; import { @@ -54,6 +55,8 @@ function bootstrap(): void { // B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a) blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute())); + // B05~B09 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1) + initLoupe(window, () => LOUPE_ROUTE.test(currentRoute())); // 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼 let shown = ""; diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts index 829e128f6..8c6d1fb38 100644 --- a/A00_Common/spreadsheet/spreadsheet_keys.ts +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -10,6 +10,7 @@ * 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음. * ========================================================================== */ +import { keyLetter } from "../key_shortcut"; import { cycleAbs } from "./spreadsheet_editor"; import { fillCells } from "./spreadsheet_fill"; import { @@ -127,7 +128,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl })); return true; } - const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; + const k = keyLetter(e) || (e.key.length === 1 ? e.key.toLowerCase() : e.key); if (ctrl && !e.altKey) { switch (k) { case "z": @@ -233,7 +234,8 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks } function onKey(e: KeyboardEvent): void { - if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫 + // 한글 조합 중 — 브라우저 몫 (Ctrl+글자 단축키는 조합 중에도 판정 · 한글 자판 대응 214-2) + if ((e.isComposing || e.keyCode === 229) && !((e.ctrlKey || e.metaKey) && keyLetter(e))) return; const inBar = e.target === ed.formula; const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e); if (done) { diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 20429f91a..bbbb4973c 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -91,7 +91,7 @@ def _build( return detail, carried -# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) +# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1) · 유입 흙막이만 + 0.7(211-1) PIPE_HEIGHT_KEYS = ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m") @@ -130,7 +130,10 @@ def _apply_new_defaults( if diameter_m > 0: for key in PIPE_HEIGHT_KEYS: if key in keys: - options.setdefault(key, pipe_wall_min_height_m(diameter_m)) + side = ( + "inlet" if key == "inlet_revet_height_m" else "" + ) # 유입 흙막이 + 0.7(211-1) + options.setdefault(key, pipe_wall_min_height_m(diameter_m, side)) if point.facility == "ford_bridge" and "ford_height_m" not in options: flow = (flows or {}).get(round(point.chainage_m, 2)) height = ford_height_m(flow, float(options.get("ford_width_m") or 0)) diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index bf89201a6..05e7e1310 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -219,6 +219,8 @@ export interface ProfileAlignmentSaveResponse { route_id: number; profile_alignment: unknown; grade_summary: RouteGradeSummary | null; + /** 측점 · 관 · 구조물 자리가 없어 서버가 버린 계획고 편집 자리(m · 213-2). */ + dropped_edit_chainages?: number[]; } /** diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 254b252d8..d5e0f1223 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -158,6 +158,86 @@ def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray) return np.interp(targets, pvi_s, pvi_z) +#: 반올림만 다른 같은 측점(m) — 관 측점 0.001m(자동설계) ↔ 0.01m(화면 저장) 차이. +#: 화면 `SAME_STATION_SNAP_M`(B05_Profile_UI_Profile_Alignment.ts) 와 짝. +SAME_STATION_SNAP_M = 0.006 + + +def _nearest_station(value: float, station_s: np.ndarray) -> float | None: + if not station_s.size: + return None + near = float(station_s[int(np.argmin(np.abs(station_s - value)))]) + return near if abs(near - value) < SAME_STATION_SNAP_M else None + + +def snap_to_stations( + base_s: np.ndarray, + base_z: np.ndarray, + edits: dict[str, dict[str, float]], + station_s: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, dict[str, dict[str, float]]]: + """측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1). + + 관 측점이 반올림으로 옮겨도(263.206 → 263.21) 꼭짓점 · 편집 키는 옛 글자로 남아, 새 측점 + 편집과 0.004m 사이 두 꼭짓점이 됨(−15000% 계단 · 숨은 측점). 겹치면 측점 위 것이 이김. + 옮긴 꼭짓점은 옛 선의 측점 높이를 받아 측점 계획고가 안 바뀜. 양 끝 · 측점에서 먼 키는 그대로. + """ + last = len(base_s) - 1 + nodes: dict[float, tuple[float, float, float]] = {} + for index, (s, z) in enumerate(zip(base_s.tolist(), base_z.tolist())): + near = None if index in (0, last) else _nearest_station(s, station_s) + at = s if near is None else near + gap = 0.0 if near is None else abs(near - s) + slot = round(at, _KEY_DECIMALS) + if slot not in nodes or gap < nodes[slot][0]: + # 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로 + nodes[slot] = (gap, at, z if near is None else float(np.interp(at, base_s, base_z))) + ordered = [nodes[slot] for slot in sorted(nodes)] + snapped: dict[str, dict[str, float]] = {} + for name, values in edits.items(): + best: dict[str, tuple[float, float]] = {} + for key, value in values.items(): + try: + chainage = float(key) + except (TypeError, ValueError): + chainage = float("nan") + near = _nearest_station(chainage, station_s) if np.isfinite(chainage) else None + target = key if near is None else chainage_key(near) + gap = 0.0 if near is None else abs(near - chainage) + if target not in best or gap < best[target][0]: + best[target] = (gap, value) + snapped[name] = {key: value for key, (_, value) in best.items()} + return ( + np.array([item[1] for item in ordered], dtype=np.float64), + np.array([item[2] for item in ordered], dtype=np.float64), + snapped, + ) + + +def drop_unplaced_edits( + edits: dict[str, dict[str, float]], places: np.ndarray +) -> tuple[dict[str, dict[str, float]], list[float]]: + """측점 · 관 · 구조물 자리(`places`) 0.006m 안에 없는 편집 키를 버린다(213-2). + + 옮긴 관의 옛 자리 · 지운 시설 자리에 남은 키는 측점선 없는 꼭짓점(유령)이 된다 — 세션 초안이 + 옛 키를 다시 보내도 [저장]에서 걸러진다. (남은 편집, 버린 자리 누가거리). + """ + kept: dict[str, dict[str, float]] = {} + dropped: set[float] = set() + for name, values in edits.items(): + kept[name] = {} + for key, value in values.items(): + try: + chainage = float(key) + except (TypeError, ValueError): + continue + if places.size and float(np.min(np.abs(places - chainage))) < SAME_STATION_SNAP_M: + kept[name][key] = value + else: + dropped.add(round(chainage, _KEY_DECIMALS)) + return kept, sorted(dropped) + + def resolve_pvi( base_s: np.ndarray, base_z: np.ndarray, @@ -407,6 +487,22 @@ def build_alignment( if value is not None } + station_s = np.array( + sorted( + float(station["chainage_m"]) + for station in stations + if isinstance(station.get("chainage_m"), (int, float)) + ), + dtype=np.float64, + ) + base_s, base_z, snapped = snap_to_stations( + base_s, + base_z, + {"station_offsets": station_offsets, "curve_radii": curve_radii}, + station_s, + ) + station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"] + pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets) curves, warnings = build_curves( pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index 3f0a47c96..1f0576dca 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -8,6 +8,7 @@ from typing import Any from uuid import UUID import aiomysql +import numpy as np from fastapi import APIRouter from fastapi.responses import JSONResponse @@ -15,6 +16,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_ from B05_Profile.B05_Profile_Debug import log_b05_debug from B05_Profile.B05_Profile_Engine import run_route_design from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options +from B05_Profile.B05_Profile_Engine_Grade_Alignment import drop_unplaced_edits from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Sections import run_section_generation from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions @@ -384,13 +386,30 @@ async def update_contour_interval( ) +def _structure_marks(project_root: Path, route_data_path: str) -> list[float]: + """관 · 구조물 자리(m) — 관 지점 전부 + 구조물 측점 자리(시작 · 기준 · 종료).""" + from B05_Profile.B05_Profile_Engine_Sections import ( + _load_pipe_points, + _load_route_polyline, + resolve_extra_stations, + ) + + pipes = _load_pipe_points(project_root, _load_route_polyline(project_root, route_data_path)) + extras = resolve_extra_stations(project_root, pipes) + return [float(pipe.chainage_m) for pipe in pipes] + [float(mark) for mark, _ in extras] + + def _apply_alignment_edits( - project_root: Path, longitudinal_file_path: str, edits: dict[str, Any] -) -> tuple[dict[str, Any], dict[str, Any]]: - """저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다. + project_root: Path, + longitudinal_file_path: str, + edits: dict[str, Any], + marks: list[float] | None = None, +) -> tuple[dict[str, Any], dict[str, Any], list[float]]: + """저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약, 버린 자리)를 반환한다. 화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에 - 남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. + 남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. `marks`(관 · 구조물 자리)를 주면 + 측점 · 관 · 구조물 어디에도 없는 편집 키를 버린다(213-2 — 저장 차례가 관 · 구조물 뒤라 최신). """ root = project_root.resolve() path = (root / longitudinal_file_path).resolve() @@ -399,6 +418,16 @@ def _apply_alignment_edits( if not path.is_file(): raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.") longitudinal = json.loads(path.read_text(encoding="utf-8")) + dropped: list[float] = [] + if marks is not None: + stations = [float(s.get("chainage_m", 0.0)) for s in longitudinal.get("stations") or []] + edits, dropped = drop_unplaced_edits( + { + "station_offsets": dict(edits.get("station_offsets") or {}), + "curve_radii": dict(edits.get("curve_radii") or {}), + }, + np.array(stations + marks, dtype=np.float64), + ) alignment, profile = rebuild_alignment_profile(longitudinal, edits) longitudinal["profile_alignment"] = alignment profiles = longitudinal.get("design_profiles") @@ -407,7 +436,7 @@ def _apply_alignment_edits( else: longitudinal["design_profiles"] = [profile] atomic_write_json(path, longitudinal) - return alignment, {"id": profile["id"], **profile["summary"]} + return alignment, {"id": profile["id"], **profile["summary"]}, dropped @router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse) @@ -426,11 +455,18 @@ async def save_profile_alignment( ) stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) - alignment, grade_summary = await asyncio.to_thread( + route = await get_latest_route(connection, project_id) + marks = None + if route and route["id"] == request.route_id and route.get("route_data_path"): + marks = await asyncio.to_thread( + _structure_marks, project_root, str(route["route_data_path"]) + ) + alignment, grade_summary, dropped = await asyncio.to_thread( _apply_alignment_edits, project_root, str(longitudinal["longitudinal_file_path"]), request.edits(), + marks, ) await connection.begin() try: @@ -446,6 +482,7 @@ async def save_profile_alignment( route_id=request.route_id, profile_alignment=alignment, grade_summary=grade_summary, + dropped_edit_chainages=dropped, ) except FileNotFoundError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index c21c05004..8e5117630 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -214,27 +214,41 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st None, ) moves[chainage_key(old)] = None if near is None else chainage_key(near) + + def moved(value: float) -> tuple[bool, str | None]: + """옛 측점 0.006m 안이면 (참, 옮길 키 · 지우면 None) — 옛 글자(263.206)도 같은 측점(213-1). + 남은 측점 위 값은 그대로.""" + if chainage_key(value) in after_keys: + return False, None + for old_key, new_key in moves.items(): + if abs(float(old_key) - value) < _SAME_STATION_M: + return True, new_key + return False, None + edits = stored.get("edits") or {} next_edits: dict[str, dict[str, Any]] = {} changed = False for name in ("station_offsets", "curve_radii"): values = dict(edits.get(name) or {}) - for old_key, new_key in moves.items(): - if old_key in values: - value = values.pop(old_key) - if new_key is not None: - values.setdefault(new_key, value) - changed = True + # 옛 측점 글자 그대로인 키가 먼저 — 반올림 글자보다 이김 + for key in sorted(values, key=lambda item: item not in moves): + hit, new_key = moved(float(key)) + if not hit: + continue + value = values.pop(key) + if new_key is not None: + values.setdefault(new_key, value) + changed = True next_edits[name] = values base: list[dict[str, Any]] = [] for node in stored["base_pvi"]: - key = chainage_key(node["chainage_m"]) - if key not in moves: + hit, new_key = moved(float(node["chainage_m"])) + if not hit: base.append(node) continue changed = True - if moves[key] is not None: - base.append({**node, "chainage_m": float(moves[key])}) + if new_key is not None: + base.append({**node, "chainage_m": float(new_key)}) if not changed: return None stored["base_pvi"] = base diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 9540d6bfc..88e90e6b1 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -173,7 +173,9 @@ async def apply_profile_target( root = Path(resolve_stored_project_path(stored_path)) relative = str(row["longitudinal_file_path"]) count = await asyncio.to_thread(_set_base_pvi, root / relative, *target) - _alignment, summary = await asyncio.to_thread(_apply_alignment_edits, root, relative, {}) + _alignment, summary, _dropped = await asyncio.to_thread( + _apply_alignment_edits, root, relative, {} + ) async with pool.acquire() as connection: await connection.begin() await update_longitudinal_grade_summary( diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index e6407c988..804107d7f 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -225,6 +225,8 @@ class ProfileAlignmentSaveResponse(BaseModel): route_id: int profile_alignment: dict[str, Any] grade_summary: dict[str, Any] | None = None + #: 측점 · 관 · 구조물 자리가 없어 버린 계획고 편집 자리(m) — 화면이 알림으로 보임(213-2) + dropped_edit_chainages: list[float] = Field(default_factory=list) class RouteSolveResponse(BaseModel): diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index c52541f09..8518459e2 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -32,6 +32,7 @@ import { import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers"; +import { followedStation } from "./B05_Profile_UI_Profile_Alignment"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -103,20 +104,19 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { /** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */ function applyIrregularStations(stations: IrregularStation[]): void { - // 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다. - // 서버가 관 측점을 0.01m 로 반올림해 돌려주므로 0.005m 안의 새 측점은 같은 측점 — 편집을 옮김. + // 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다. + // 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2) + // — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움. const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3))); const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3))); - irregularStations - .filter((station) => !nextKeys.has(station.chainage_m.toFixed(3))) - .forEach((station) => { - const same = stations.find( - (next) => - Math.abs(next.chainage_m - station.chainage_m) < 0.006 && - !oldKeys.has(next.chainage_m.toFixed(3)), - ); - deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); - }); + const gone = irregularStations.filter( + (station) => !nextKeys.has(station.chainage_m.toFixed(3)), + ); + const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3))); + gone.forEach((station) => { + const same = followedStation(station, gone, born); + deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); + }); irregularStations = stations; deps.renderStationLines(); deps.profilePanel().setIrregularStations(withoutFordPavement(stations)); @@ -361,6 +361,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { }, /** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */ irregularStations: () => withoutFordPavement(irregularStations), + /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ + typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group, /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */ setPipes(pipes: PipeProjection[]): void { pipeStations = pipesToStations(pipes); diff --git a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts index 1a6c62b19..1346067ba 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts @@ -182,6 +182,81 @@ export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase { }; } +/** + * 사라진 비정규 측점의 편집이 따라갈 새 측점(213-2) — 0.006m 안(반올림) · 같은 id · 사라진 것 하나와 + * 새것 하나(옮긴 관)면 그것. 없으면 null(편집을 지움). + */ +export function followedStation( + station: T, + gone: ReadonlyArray, + born: ReadonlyArray, +): T | null { + return ( + born.find((next) => Math.abs(next.chainage_m - station.chainage_m) < 0.006) ?? + born.find((next) => next.id === station.id) ?? + (gone.length === 1 && born.length === 1 ? born[0] : null) + ); +} + +/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */ +const SAME_STATION_SNAP_M = 0.006; + +function nearestStation(value: number, stationS: number[]): number | null { + let near: number | null = null; + stationS.forEach((station) => { + if (near === null || Math.abs(station - value) < Math.abs(near - value)) near = station; + }); + return near !== null && Math.abs(near - value) < SAME_STATION_SNAP_M ? near : null; +} + +function snapKeys(values: Record, stationS: number[]): Record { + const best = new Map(); + Object.entries(values).forEach(([key, value]) => { + const chainage = Number(key); + const near = Number.isFinite(chainage) ? nearestStation(chainage, stationS) : null; + const target = near === null ? key : chainageKey(near); + const gap = near === null ? 0 : Math.abs(near - chainage); + const held = best.get(target); + if (!held || gap < held.gap) best.set(target, { gap, value }); + }); + return Object.fromEntries([...best].map(([key, entry]) => [key, entry.value])); +} + +/** + * 측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1) — 서버 + * `snap_to_stations` 와 같은 규칙. 관 측점이 반올림으로 옮겨도(263.206 → 263.21) 옛 글자로 남아 + * 0.004m 사이 두 꼭짓점이 되던 흠. 겹치면 측점 위 것이 이김 · 옮긴 꼭짓점은 옛 선의 측점 높이 + * (측점 계획고 그대로) · 양 끝 · 측점에서 먼 키는 그대로. + */ +function snapToStations( + base: AlignmentBase, + edits: AlignmentEdits, +): { basePvi: AlignmentNode[]; edits: AlignmentEdits } { + const stationS = base.stations.map((station) => station.chainage_m); + const baseS = base.basePvi.map((node) => node.chainage_m); + const baseZ = base.basePvi.map((node) => node.elevation_m); + const last = base.basePvi.length - 1; + const nodes = new Map(); + base.basePvi.forEach((node, index) => { + const near = index === 0 || index === last ? null : nearestStation(node.chainage_m, stationS); + const at = near ?? node.chainage_m; + const gap = near === null ? 0 : Math.abs(near - node.chainage_m); + const slot = Number(at.toFixed(3)); + const held = nodes.get(slot); + // 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로 + const elevation = near === null ? node.elevation_m : interpolate(baseS, baseZ, at); + if (!held || gap < held.gap) + nodes.set(slot, { gap, node: { chainage_m: at, elevation_m: elevation } }); + }); + return { + basePvi: [...nodes.keys()].sort((a, b) => a - b).map((slot) => nodes.get(slot)!.node), + edits: { + station_offsets: snapKeys(edits.station_offsets, stationS), + curve_radii: snapKeys(edits.curve_radii, stationS), + }, + }; +} + /** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */ function resolvePvi( base: AlignmentBase, @@ -316,8 +391,10 @@ function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits { }; } -export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): ProfileAlignment { - const edits = normalizeEdits(input); +export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): ProfileAlignment { + const snapped = snapToStations(raw, normalizeEdits(input)); + const base: AlignmentBase = { ...raw, basePvi: snapped.basePvi }; + const edits = snapped.edits; const warnings: string[] = []; const nodes = resolvePvi(base, edits.station_offsets); const pviS = nodes.map((node) => node.chainage_m); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index d50323666..61b8c5add 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -9,6 +9,7 @@ * 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조. * ========================================================================== */ +import { isCtrlKey } from "../A00_Common/key_shortcut"; import type { AlignmentBase, AlignmentEdits, @@ -26,6 +27,9 @@ import { tiltStraightRun, } from "./B05_Profile_UI_Profile_Straighten"; import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; +import { createFreeDraw } from "../A00_Common/design/design_profile_free"; +import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; +import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; /** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ const KEY_STEP_M = 0.1; @@ -111,6 +115,18 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { pause: () => (syncPipes(), inner.pause()), }; + // [자유지정] — 적용은 직선화 결과와 같은 길(applyEdits · 이력 · 막기 규칙)(215-1). + const free = createFreeDraw({ + base: ctx.base, + alignment: ctx.alignment, + edits: ctx.edits, + apply: (next) => ctx.applyEdits(next), + close: () => tools.clear(), + changed: ctx.refresh, + }); + const freeView = createFreeView(free); + let lastGeom: FreeChartGeom | null = null; + const tools = createProfileTools({ onStraighten: (fromM, toM) => { const base = ctx.base(); @@ -143,12 +159,19 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { return alignment ? hasStraightRuns(alignment) : false; }, onChanged: ctx.refresh, + onModeChange: (mode) => freeView.mount(lastGeom, mode === "free"), + freeHint: () => free.hint(), + mountOverlay: (geom) => { + lastGeom = geom; + freeView.mount(geom, tools.mode() === "free"); + }, }); /** 그래프에서 누른 자리를 도구가 먹는다. */ function handleToolPick(chainageM: number | null): boolean { const mode = tools.mode(); if (mode === "none") return false; + if (mode === "free") return true; // 자유지정 클릭은 오버레이가 먼저 먹는다. if (chainageM === null) return tools.handleRunPick(null); const alignment = ctx.alignment(); if (mode === "shift") { @@ -200,6 +223,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); return true; } + // 좌우 이동 = 마지막 누름이 종단도 안일 때만 — 횡단 카드 · 3D 를 누른 뒤 방향키가 관을 몰래 옮겼음(219-1) + if (!arrowsBelongToProfile()) return false; // 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져 // (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측). const station = holdStation ?? selectedIrregular(); @@ -283,7 +308,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. if (target && target.closest("input, select, textarea")) return; const key = event.key; - if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) { + // [자유지정] 중 Enter 끝 · Esc 취소 · Backspace 점 빼기 — 단추 초점의 Enter 누름도 막는다. + if (tools.mode() === "free" && free.key(key)) { + event.preventDefault(); + freeView.redraw(); + return; + } + if (isCtrlKey(event, "z")) { event.preventDefault(); flushHold(); if (event.shiftKey) history.redo(); diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index b7f45b834..af5042efd 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -34,7 +34,7 @@ import { Y_AXIS_WINDOW_CLASS, type ElevationWindowResult, } from "@util/common_util_chart_ywindow"; -import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; +import type { ProfileTools } from "./B05_Profile_UI_Profile_Tools"; import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; import type { ProfileZoomState } from "./B05_Profile_UI_Profile_Zoom"; @@ -106,8 +106,8 @@ export interface ProfileRenderContext { ) => { min: number; max: number } | undefined; /** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */ toolActive: () => boolean; - /** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */ - selectedRuns: () => StraightRun[]; + /** [쉬프트]로 고른 직선 구간(빨간 강조) · [자유지정] 오버레이(215-1). */ + tools: Pick; stationIdAtStructure: (structureId: string | null) => string | null; /** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */ redraw: () => void; @@ -368,7 +368,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { if (alignment) { const highlight = createRunHighlight({ alignment, - runs: ctx.selectedRuns(), + runs: ctx.tools.selectedRuns(), x, axis: yAxis, widthPx: width, @@ -403,6 +403,16 @@ export function renderProfile(ctx: ProfileRenderContext): void { canvas.append(chartWrap, structureLane); body.replaceChildren(canvas); body.scrollLeft = scrollLeft; + // [자유지정] — 범위 강조 · 점선 미리 보기 · 클릭 점(215-1). 화면에 붙인 뒤라야 자리를 잰다. + ctx.tools.mountOverlay({ + chart: chartWrap, + scroller: body, + x, + toChainage, + widthPx: width, + heightPx: chartHeight, + stations: graphLongitudinal.stations.map((station) => station.chainage_m), + }); // 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다. if (table) table.style.width = `${width}px`; tableOverlay.setTable(table); diff --git a/B05_Profile/B05_Profile_UI_Profile_Straighten.ts b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts index 5b9279de8..74bc054e7 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Straighten.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts @@ -46,7 +46,7 @@ function pviChainages(alignment: ProfileAlignment): number[] { } /** 자동 선형(base_pvi) 위에서의 표고 — 편집 델타의 기준값. */ -function baseElevationAt(base: AlignmentBase, chainageM: number): number { +export function baseElevationAt(base: AlignmentBase, chainageM: number): number { const nodes = base.basePvi; if (!nodes.length) return 0; if (chainageM <= nodes[0].chainage_m) return nodes[0].elevation_m; @@ -181,7 +181,7 @@ export function straightenBetween( } /** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */ -function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number { +export function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number { const length = base.policy.default_curve_length_m; if (typeof length === "number" && Number.isFinite(length) && length > 0) return length; return base.policy.default_curve_radius_m * Math.abs(deltaGrade); diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index fd33a05ff..420d00159 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -11,12 +11,14 @@ * [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림. * 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에 * 라운드가 생기고 양쪽이 탄젠트 직선이 된다. + * [자유지정] → 범위를 고르고 클릭으로 계획선을 잇는다(`design_profile_free` 몫 · 215-1). * ========================================================================== */ import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; import { sameRun } from "./B05_Profile_UI_Profile_Straighten"; +import type { FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; -export type ProfileToolMode = "none" | "straighten" | "shift"; +export type ProfileToolMode = "none" | "straighten" | "shift" | "free"; export interface ProfileToolsCallbacks { /** 두 측점을 직선으로 잇는다. */ @@ -36,6 +38,12 @@ export interface ProfileToolsCallbacks { hasStraightRuns: () => boolean; /** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */ onChanged: () => void; + /** 모드가 바뀜 — [자유지정] 오버레이를 붙이고 뗀다. */ + onModeChange: (mode: ProfileToolMode) => void; + /** [자유지정] 단계 안내. */ + freeHint: () => string; + /** 그래프를 새로 그림 — [자유지정] 오버레이를 다시 붙인다. */ + mountOverlay: (geom: FreeChartGeom) => void; } export interface ProfileTools { @@ -54,6 +62,8 @@ export interface ProfileTools { nudge: (delta: number) => boolean; /** 모드·선택을 모두 끈다. */ clear: () => void; + /** 그래프를 새로 그릴 때마다 — [자유지정] 오버레이 자리. */ + mountOverlay: (geom: FreeChartGeom) => void; } function toolButton(label: string, title: string, onClick: () => void): HTMLButtonElement { @@ -78,6 +88,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo mode = "none"; pending = null; runs = []; + callbacks.onModeChange(mode); } function setMode(next: ProfileToolMode): void { @@ -87,6 +98,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo mode = next; pending = null; runs = []; + callbacks.onModeChange(mode); } callbacks.onChanged(); } @@ -106,6 +118,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo nudge: step, selectedRuns: () => runs, pendingStation: () => pending, + mountOverlay: callbacks.mountOverlay, clear() { if (mode === "none" && pending === null && !runs.length) return; reset(); @@ -174,15 +187,26 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo () => setMode("shift"), ); shift.classList.toggle("is-active", mode === "shift"); + const free = toolButton( + "자유지정", + "범위를 고른 뒤 클릭으로 계획선을 잇습니다 (Enter 끝 · Backspace 점 빼기 · Esc 취소)", + () => setMode("free"), + ); + free.classList.toggle("is-active", mode === "free"); const up = toolButton("▲", "고른 직선을 0.1m 올림", () => void step(0.1)); const down = toolButton("▼", "고른 직선을 0.1m 내림", () => void step(-0.1)); - const idle = mode === "none" || (!runs.length && mode === "shift"); + const idle = mode === "none" || mode === "free" || (!runs.length && mode === "shift"); up.disabled = idle || (mode === "straighten" && runs.length !== 1); down.disabled = up.disabled; - wrap.append(reset, undo, redo, straighten, shift, up, down); - if (mode === "straighten" && pending !== null) { + wrap.append(reset, undo, redo, straighten, free, shift, up, down); + if (mode === "free") { + const hint = document.createElement("em"); + hint.className = "b05-route-profile__tool-hint"; + hint.textContent = callbacks.freeHint(); + wrap.append(hint); + } else if (mode === "straighten" && pending !== null) { const hint = document.createElement("em"); hint.className = "b05-route-profile__tool-hint"; hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index fda74f078..d55eb6e18 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -10,6 +10,8 @@ * 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다. * ========================================================================== */ +import { keyLetter } from "../A00_Common/key_shortcut"; + export type Vertex = [number, number]; /** 되돌릴 수 있는 편집 상태 한 벌. */ @@ -154,7 +156,7 @@ export function bindHistoryControls(params: HistoryControlsParams): { const onKey = (event: KeyboardEvent): void => { if (!(event.ctrlKey || event.metaKey)) return; - const key = event.key.toLowerCase(); + const key = keyLetter(event); if (key === "z" && !event.shiftKey) step("undo"); else if (key === "y" || (key === "z" && event.shiftKey)) step("redo"); else return; diff --git a/B05_Profile/B05_Profile_UI_Selection.ts b/B05_Profile/B05_Profile_UI_Selection.ts index cc4f393ed..23efb7f6f 100644 --- a/B05_Profile/B05_Profile_UI_Selection.ts +++ b/B05_Profile/B05_Profile_UI_Selection.ts @@ -27,6 +27,9 @@ export interface SelectionSyncPorts { selectGraph: (stationId: string | null, chainageM?: number | null) => void; /** 좌측 구조물 폼 선택(누가거리 기준). */ selectSidebar: (chainageM: number | null) => void; + /** 측점 하나 고름(측점선 · 3D 띠) → 좌측 폼 — 횡단 카드 고름과 같은 규칙(관 → 덮는 구조물 · 노면공 뺌 → + * 없으면 폼 비움 · 216-1). 없으면 selectSidebar. */ + openAtStation?: (chainageM: number | null) => void; /** 배수유역도 세부유역 강조(누가거리 기준). */ selectBasin: (chainageM: number | null) => void; /** 배수유역도 계획선 위 측점 마킹(누가거리 기준). 유역 없는 구조물도 위치를 보여 준다. */ @@ -79,17 +82,18 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { // 측점선 · 3D 띠 · 표 고름도 고름 한 칸에 — 일반 측점을 안 적어 옛 배관 고름이 이겼음(188-1) const at = stationId === null ? null : (station?.chainage_m ?? ports.regularChainage?.(stationId)); - if (at !== undefined) ports.remember?.(at); - // 규칙 측점을 고른 것이면 구조물 폼은 건드리지 않는다(고를 항목이 없다). - if (stationId !== null && !stationId.startsWith(prefix)) return; + if (at === undefined) return; // 모르는 측점 — 폼 · 고름 한 칸 안 건드림 ports.setSyncing(true); try { - ports.selectSidebar(station ? station.chainage_m : null); + // 일반 측점도 폼이 따라감 — 일반 측점이면 폼을 안 건드려 옛 폼이 남았음(B06 카드 규칙 · 216-1) + (ports.openAtStation ?? ports.selectSidebar)(at); ports.selectPipeForm?.(station && isPipeStation(station) ? station.chainage_m : null); ports.selectStructure3D?.(station ? station.chainage_m : null); } finally { ports.setSyncing(false); } + // 폼이 연 구조물 · 푼 관 알림이 적은 고름을 이 측점으로 덮음 — 마지막 고름 하나(188-1) + ports.remember?.(at); }, selectStationOfPipe(chainageM) { if (ports.isSyncing()) return; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 6ea28ae39..2b7e97e5e 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -617,8 +617,8 @@ export function createStructuresSection( loadPipeForm(pipe); }, resolveAt: (selection) => resolveSelection(selection, pipeFacilities, structures), - selectAt(selection) { - const hit = resolveSelection(selection, pipeFacilities, structures); + selectAt(selection, coverable) { + const hit = resolveSelection(selection, pipeFacilities, structures, coverable); if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM); else this.selectById(hit.structureId); return hit; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 7183dc422..589623338 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -174,8 +174,11 @@ export interface StructuresSection { /** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). */ resolveAt: (selection: DesignSelection) => ResolvedSelection; /** 고름 한 값으로 폼을 연다(관 → 구조물 순) — 어느 화면에서 골라도 같은 거리 · 같은 순서다. - * 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. */ - selectAt: (selection: DesignSelection) => ResolvedSelection; + * 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. `coverable` = 덮어 열어도 되는 구조물(측점 고름 · 216-1). */ + selectAt: ( + selection: DesignSelection, + coverable?: (entry: StructureInstance) => boolean, + ) => ResolvedSelection; /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ addAt: (chainageM: number, typeId: string) => void; /** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */ diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index d8fcc2277..ed8512694 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -569,7 +569,7 @@ } /* 상자 안 작은 단추 템플릿(54-41) — 높이 = 숫자 칸 [-][+](`--b05-control-h`) · 글 · 안 여백도 그 높이에 · - * 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 제외 */ + * 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 아래 205-1 */ .b05-structure__group-body button:not(.b05-structure__step), .b05-structure__group > legend > button { box-sizing: border-box; @@ -580,6 +580,24 @@ line-height: 1; } +/* 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄(205-1) — 단추 높이 = 상자 안 작은 단추(`--b05-control-h`) · + * 폼이 길어도 스크롤 영역 아래 끝에 붙어 늘 보임(자리 고정 · 뒤 내용이 비치지 않게 불투명 배경) */ +.b05-structure-section .b05-route__irregular-actions { + position: sticky; + bottom: 0; + z-index: 1; + background: var(--color-surface-raised); +} + +.b05-structure-section .b05-route__irregular-actions > button { + box-sizing: border-box; + height: var(--b05-control-h); + min-height: 0; + padding-block: 0; + font-size: var(--text-caption); + line-height: 1; +} + /* 노면공 「확폭 구간」 상자(PLAN 87-3) — 구간마다 단추 한 줄 */ .b05-structure__widening[hidden] { display: none; diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 91e5bfd69..6584eb291 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -334,6 +334,56 @@ opacity: 0.5; } +/* [자유지정] 오버레이 — 범위 띠 · 지나온 점선 · 마우스까지 미리 보기 · 기울기 글(215-1). + 기준 기울기를 넘는 구간은 빨강. 클릭은 그래프 래퍼가 받으므로 오버레이는 마우스를 통과시킨다. */ +.b05-profile-freemark { + position: absolute; + z-index: 5; + inset: 0; + pointer-events: none; +} + +.b05-profile-freemark__band { + fill: color-mix(in srgb, var(--color-primary) 10%, transparent); +} + +.b05-profile-freemark__edge { + stroke: var(--color-primary); + stroke-width: 1.5; +} + +.b05-profile-freemark__line { + stroke: var(--color-primary); + stroke-width: 2; + stroke-dasharray: 6 4; +} + +.b05-profile-freemark__line.is-preview { + opacity: 0.7; +} + +.b05-profile-freemark__line.is-over { + stroke: var(--color-danger, #d64545); +} + +.b05-profile-freemark__dot { + fill: var(--color-surface); + stroke: var(--color-primary); + stroke-width: 1.5; +} + +.b05-profile-freemark__grade { + fill: var(--color-text); + font-size: var(--text-caption); + paint-order: stroke; + stroke: var(--color-surface); + stroke-width: 3px; +} + +.b05-profile-freemark__grade.is-over { + fill: var(--color-danger, #d64545); +} + .b05-profile-edit { position: absolute; z-index: 4; diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 88f8e135f..7c09a92ed 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -633,8 +633,16 @@ export interface CrossSectionPatch { export type CrossCellSource = | { src: "area"; key: string } | { src: "length"; start: [number, number]; end: [number, number] } + | { src: "length" | "area"; pieces: CrossPickPiece[] } | { src: "text"; text: string }; +/** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */ +export interface CrossPickPiece { + line: "design" | "ground" | "spoil" | "structure"; + start: [number, number]; + end: [number, number]; +} + /** [상세] 표 손질 — 칸 출처(표 키 →) · 더한 행(`after` 아래 · 없으면 끝) · 더한 열(오른쪽 끝). */ export interface CrossDetailTable { cells: Record; diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 24ce66c42..4f50a3c08 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -78,19 +78,23 @@ REVET_FACE_SLOPE = 0.3 # `test_b06_pipe_wall_height_mirror`. REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금 REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이 +# 관 보호공 **유입** 흙막이 빈 칸 기본 = 관경(올림) + 0.7 — 유출 · 집수정은 근입 0.5 그대로(211-1). +# ⚠ TS 짝 `common_util_culvert_sets.INLET_REVET_ADD_M` · `_Cross_Culvert_Const.INLET_REVET_ADD_M`. +INLET_REVET_ADD_M = 0.7 # 옛 정의 기본 높이(10-6 표 · 관경 무관) — 관경 > 1.0 관에 이 값이면 관 윗면보다 낮은 벽이라 # 읽을 때 관경 기준 값으로 봄(139-1). OLD_PIPE_WALL_DEFAULT_M = 1.5 -def pipe_wall_min_height_m(diameter_m: float) -> float: +def pipe_wall_min_height_m(diameter_m: float, side: str = "") -> float: """관 벽 **순수 높이**(근입 포함) 기본값 = 관경(0.1m 로 올림) + 근입 0.5 — 벽 윗면 = 관 윗면(139-1). - 관 · 유입 집수정 높이 칸의 새 기본값이자 빈 칸 값. + 관 · 유입 집수정 높이 칸의 새 기본값이자 빈 칸 값. `side="inlet"`(유입 흙막이) = + 0.7(211-1). """ raw = float(diameter_m) steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) - return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3) + add = INLET_REVET_ADD_M if side == "inlet" else REVET_EMBED_DEPTH_M + return round(steps * REVET_HEIGHT_STEP_M + add, 3) def pipe_wall_height_m(stored: float | None, diameter_m: float) -> float | None: @@ -211,10 +215,11 @@ def _side_spec( spec["basin_form"] = str(options.get("inlet_structure") or "") or None return spec - # 높이를 안 적었으면 **관경 기준 높이**(관경 + 0.5 · 139-1) · 옛 1.5 도 관경 > 1.0 이면 그 값. + # 높이를 안 적었으면 **관경 기준 높이**(관경 + 0.5 · 유입 + 0.7 · 139-1 · 211-1) · + # 옛 1.5 도 관경 > 1.0 이면 관경 + 0.5(저장 값 해석 · 안 바뀜). height = pipe_wall_height_m(_number(options.get(f"{side}_revet_height_m"), None), diameter_m) if height is None: - height = pipe_wall_min_height_m(diameter_m) + height = pipe_wall_min_height_m(diameter_m, side) length = _number( options.get(f"{side}_revet_length_m"), None, diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index 1bc4121df..3249f58da 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -121,7 +121,11 @@ class _SectionGeometry: # ⚠ 옛 저장분(`ditch_choice` 가 없던 것)은 `legacy_ditch_enabled` 로 온다. 그 값은 # **자동값과 다를 때만 뜻이 있다** — 같으면 자동이 그렇게 냈던 것이고, 다르면 # 사용자가 일부러 바꾼 것이다. 그래서 **다를 때만** 선택으로 살린다(설계 의도 보존). - if section_mode == "both_fill": + # ⚠ 양측 성토는 **판정된 역할**로 가른다(206-1) — 입력 `section_mode` 는 옛 저장값일 수 + # 있다(계획고를 올려 우 절토 → 양측 성토 · 입력은 right_cut 그대로). 입력으로 가르면 옛 + # 측구 결과가 선택으로 둔갑해 양측 성토 단면에 측구가 섰다. ⚠ TS 짝 `SectionGeometry`. + both_fill = self.left_role == "fill" and self.right_role == "fill" + if both_fill: auto_ditch = False elif ground_at is not None: ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent @@ -136,10 +140,10 @@ class _SectionGeometry: # 없는데, 그것을 선택으로 굳히면 **다시 같은 병**(지형이 바뀌어도 안 따라감)이 된다. if choice is not None and bool(choice) == auto_ditch: choice = None - # 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다. - self.has_ditch = auto_ditch if (choice is None or section_mode == "both_fill") else choice + # 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저 · 남은 선택은 비움. + self.has_ditch = auto_ditch if (choice is None or both_fill) else choice #: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. - self.ditch_choice = choice + self.ditch_choice = None if both_fill else choice # 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** # 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. diff --git a/B06_Section/B06_Section_Router_CrossTable.py b/B06_Section/B06_Section_Router_CrossTable.py index e13d35579..74d30efed 100644 --- a/B06_Section/B06_Section_Router_CrossTable.py +++ b/B06_Section/B06_Section_Router_CrossTable.py @@ -50,7 +50,7 @@ def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None) key: _format(value) if isinstance(value, (int, float)) else "-" for key, value in table.items() } - _values, texts, repick = edited_cells(design) + _values, texts, repick = edited_cells(design, source.get("samples")) text.update(texts) return { "quantity_table": table, diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index df1c4eb4e..3c8172d2c 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -20,6 +20,7 @@ from __future__ import annotations import asyncio import logging +import math from pathlib import Path from typing import Any from uuid import UUID @@ -28,6 +29,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Engine_Grade_Alignment import SAME_STATION_SNAP_M from B05_Profile.B05_Profile_Repository import ( get_latest_route, get_surface_crs_epsg, @@ -41,10 +43,9 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B06 Section"]) -#: 「그 자리에 측점이 있다」고 볼 거리(m) — 측점 생성의 스냅 폭과 같음 -#: (`B05_Profile_Engine_Sections_Core` · 같은 횡단 이름이 되는 0.005m 안만 한 측점). +#: 「그 자리에 측점이 있다」고 볼 거리(m) — 같은 측점 폭(213-1 · 반올림 차이 0.006m) 한 벌(219-1). #: 넓으면 조금 옮긴 관(769.28 → 769.38)을 「측점 있음」으로 세어 측점이 옛 자리에 남음(192-1). -STATION_MATCH_TOLERANCE_M = 0.005 +STATION_MATCH_TOLERANCE_M = SAME_STATION_SNAP_M SNAPSHOT_MISSING = ( "지표 샘플링 조건을 찾을 수 없어 측점을 만들 수 없음 — 1단계(지표 확정)를 마친 뒤" @@ -96,9 +97,15 @@ def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, A if not extras: return [] existing = _station_chainages(project_root) + # 노선 밖(0 미만 · 끝 넘음) = 측점을 못 세움 — 판정에서 뺌(219-1 · 길이 모르면 끝은 안 봄) + end_m = sum(math.dist(a[:2], b[:2]) for a, b in zip(polyline, polyline[1:])) missing = [] for chainage, label in extras: value = float(chainage) + if value < -STATION_MATCH_TOLERANCE_M or ( + end_m > 0 and value > end_m + STATION_MATCH_TOLERANCE_M + ): + continue if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing): continue missing.append({"chainage_m": round(value, 3), "label": label}) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index d0ee0e92c..fae03161c 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -135,14 +135,24 @@ class BoxAdjustPatch(BaseModel): right: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch) +class CrossPickPiece(BaseModel): + """[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고).""" + + line: Literal["design", "ground", "spoil", "structure"] + start: list[float] = Field(..., min_length=2, max_length=2) + end: list[float] = Field(..., min_length=2, max_length=2) + + class CrossCellSource(BaseModel): - """[상세] 표 칸 출처(179-4) — area = 설계 면적 칸 · length = 두 끝(m · offset, 표고) · text.""" + """[상세] 표 칸 출처(179-4) — area = 설계 면적 칸 · length = 두 끝(m · offset, 표고) · text · + pieces(209-1) = 고른 선 조각 목록(length = 길이 합 · area = 닫힌 넓이).""" src: Literal["area", "length", "text"] key: str | None = Field(default=None, max_length=40) start: list[float] | None = Field(default=None, min_length=2, max_length=2) end: list[float] | None = Field(default=None, min_length=2, max_length=2) text: str | None = Field(default=None, max_length=200) + pieces: list[CrossPickPiece] | None = Field(default=None, min_length=1, max_length=200) class CrossDetailRow(BaseModel): diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 673bfeaaf..a6ac1bf2b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -71,6 +71,10 @@ export function pipeWallThicknessM(kind: string | null, diameterM: number): numb */ export const REVET_EMBED_DEPTH_M = 0.5; +/** 관 보호공 **유입** 흙막이 빈 칸 기본 순수 높이 = 관경(올림) + 0.7(211-1) — 유출은 근입 0.5 그대로. + * ⚠ 파이썬 짝 `INLET_REVET_ADD_M` · 세트 짝 `common_util_culvert_sets.INLET_REVET_ADD_M`(거울 시험). */ +export const INLET_REVET_ADD_M = 0.7; + /* 기슭막이 높이는 형태와 무관하게 **사용자 입력 그대로** — 형태별 높이 한계·원지반 맞춤 * 자동 자르기 없음. 형태가 바꾸는 것은 안 무늬뿐. 높이 기준은 **순수 높이**(벽 바닥~상단, * 근입 0.5m 포함). 단 하나 — 벽 윗면은 노면 위로 올라오지 않음(`buildRevetWallGeometry`). */ @@ -138,7 +142,7 @@ export function revetWallSpec( // 관경 기준 높이는 기본값만 — 하한은 그림이 서는 값뿐(54-51 · 사용자가 고름). const preferred = hiddenPipe ? (spec.revet_height_m ?? autoHeightM ?? REVET_EMBED_DEPTH_M) - : (spec.revet_height_m ?? pipeWallMinPureHeight(diameterM)); // 칸 값 → 그림(139-1) + : (spec.revet_height_m ?? pipeWallMinPureHeight(diameterM, spec.role)); // 칸 값 → 그림(139-1) const pureHeight = Math.max(adjust.h ?? preferred, WALL_MIN_PURE_HEIGHT_M); return { form, @@ -155,10 +159,12 @@ export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180); /** * 배관 기슭막이 관 하부 여유(m) — 관 invert 아래 벽 바닥까지의 깊이 = 근입 0.5. * **순수 높이 기본 = 관경 + 0.5**(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · - * 10-6 표 Ø1000 = 1.5 · 139-1). 칸을 비우면 이 값. + * 10-6 표 Ø1000 = 1.5 · 139-1 · 유입 흙막이 = 관경 + 0.7 · 211-1). 칸을 비우면 이 값. */ -export function pipeWallMinPureHeight(diameterM: number): number { - return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M; +export function pipeWallMinPureHeight(diameterM: number, side = ""): number { + return ( + revetTargetHeight(diameterM) + (side === "inlet" ? INLET_REVET_ADD_M : REVET_EMBED_DEPTH_M) + ); } /** 벽 순수 높이 하한(m) — 0 이하는 그림이 안 섬. 관경 · 근입 하한 없음(54-51). */ export const WALL_MIN_PURE_HEIGHT_M = 0.1; diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 7bc478776..0c7e07f68 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -388,8 +388,10 @@ export function buildDesignControls( groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). // 아래 옵션은 선행 조건 미충족 시 비활성 = 숨김(179-2 · CSS) — 편측의 쪽은 알약 글에. - const rockCut = isRock(state.ground) && state.mode !== "both_fill"; - const hasDitch = state.mode !== "both_fill"; + // 숨김은 「막힘 + 그려진 측구 없음」일 때만(206-1) — 측구가 그려져 있으면 단추를 보여 끌 수 있게. + const ditchDrawn = design?.ditch_enabled === true; + const rockCut = isRock(state.ground) && (state.mode !== "both_fill" || ditchDrawn); + const hasDitch = state.mode !== "both_fill" || ditchDrawn; // 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다. // 화면 표시는 **결과**를 보인다 — 선택이 없으면 자동으로 선 결과가 답이다. const ditchOn = state.ditchEnabled ?? design?.ditch_enabled ?? true; diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 4f52eab17..260405b9b 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -332,6 +332,7 @@ export function openCrossDetail(section: CrossSection): void { axes = drawn.axes; const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next)); cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : [])); + tablePick.redraw(); // 선 조각 고르기(209-1) } showPicked(); }, @@ -367,11 +368,11 @@ export function openCrossDetail(section: CrossSection): void { }, stop: () => { resize.disconnect(); - detachTable(); + tablePick.detach(); }, }; // 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음 - const detachTable = attachDetailTable({ + const tablePick = attachDetailTable({ tableBox, cad, section: () => state.section, diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts new file mode 100644 index 000000000..f137535dc --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts @@ -0,0 +1,227 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Geom.ts + * [상세] 그래프 고르기 기하(209-1) — 선을 만나는 점에서 조각으로 나눔 · 조각 길 · 길이 · 닫힌 넓이. + * + * 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py` + * (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) · + * 거울 시험 `resources/tester/test_209_1_detail_select.py`. + * 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산. + * ========================================================================== */ + +import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; + +export type Pt = [number, number]; +export type LineKind = CrossPickPiece["line"]; +/** 고를 수 있는 조각 — 어느 선 · 그 선 위 꺾은선(m). */ +export interface SplitPiece { + line: LineKind; + points: Pt[]; +} + +/** 서버 `LINE_TOLERANCE_M` 과 같은 값 — 두 끝이 선에서 이만큼 안이면 「선 위」 · 고리 끝 맞음. */ +export const LINE_TOLERANCE_M = 0.05; +/** 다른 선 끝이 이만큼 안이면 닿음으로 보고 자름(m) — 설계선 표고 0.1 mm 반올림. */ +const TOUCH_M = 0.01; +/** 이보다 짧은 조각은 버림(m) — 누를 수도 없음. */ +const MIN_PIECE_M = 0.02; + +const dist = (a: Pt, b: Pt): number => Math.hypot(a[0] - b[0], a[1] - b[1]); + +/* ── 서버 짝(검산 · 값) ─────────────────────────────────────────────────── */ + +/** 원지반선 — 무효 표고 샘플마다 끊은 꺾은선들(서버 `ground_runs` · 카드 `appendGroundProfile`). */ +export function groundRuns(samples: ReadonlyArray | undefined): Pt[][] { + const runs: Pt[][] = [[]]; + for (const sample of samples ?? []) { + const x = sample.offset_m; + const y = sample.elevation_m ?? sample.z; + if (sample.valid === false || typeof x !== "number" || typeof y !== "number") runs.push([]); + else runs[runs.length - 1].push([x, y]); + } + return runs.filter((run) => run.length > 1); +} + +const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) => + (raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite)); + +/** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */ +export function pickLines( + design: CrossDesign | undefined, + samples: ReadonlyArray | undefined, +): Record { + return { + design: [polyline(design?.design_line)], + ground: groundRuns(samples), + spoil: [polyline(design?.spoil_fill_line)], + }; +} + +/** 선 위 자리(앞 끝에서 잰 길이 m) — 허용 안 가장 가까운 곳 · 없으면 null. */ +function locate(point: Pt, run: Pt[]): number | null { + let best: [number, number] | null = null; + let along = 0; + for (let i = 0; i + 1 < run.length; i++) { + const [ax, ay] = run[i]; + const [bx, by] = run[i + 1]; + const dx = bx - ax; + const dy = by - ay; + const size = Math.hypot(dx, dy); + const raw = size === 0 ? 0 : ((point[0] - ax) * dx + (point[1] - ay) * dy) / size ** 2; + const t = Math.max(0, Math.min(1, raw)); + const gap = Math.hypot(point[0] - (ax + t * dx), point[1] - (ay + t * dy)); + if (gap <= LINE_TOLERANCE_M && (best === null || gap < best[0])) best = [gap, along + t * size]; + along += size; + } + return best ? best[1] : null; +} + +function at(run: Pt[], s: number): Pt { + for (let i = 0; i + 1 < run.length; i++) { + const [ax, ay] = run[i]; + const [bx, by] = run[i + 1]; + const size = Math.hypot(bx - ax, by - ay); + if (s <= size) { + const t = size === 0 ? 0 : s / size; + return [ax + t * (bx - ax), ay + t * (by - ay)]; + } + s -= size; + } + return run[run.length - 1]; +} + +/** 선 위 두 자리 사이 꺾은선(s0 → s1 차례) — 사이 꼭짓점 포함(서버 `sub_path`). */ +export function subPath(run: Pt[], s0: number, s1: number): Pt[] { + const lo = Math.min(s0, s1); + const hi = Math.max(s0, s1); + const inner: Pt[] = []; + let along = 0; + for (let i = 0; i + 1 < run.length; i++) { + along += dist(run[i], run[i + 1]); + if (lo < along && along < hi) inner.push(run[i + 1]); + } + const path = [at(run, lo), ...inner, at(run, hi)]; + return s0 <= s1 ? path : path.reverse(); +} + +/** 고른 조각 → 그 선 위 꺾은선 · 두 끝이 선 밖이면 null(구조물 선 = 두 끝 직선 · 검산 없음). */ +export function piecePath(piece: CrossPickPiece, lines: Record): Pt[] | null { + if (piece.line === "structure") return [piece.start, piece.end]; + for (const run of lines[piece.line] ?? []) { + const s0 = locate(piece.start, run); + const s1 = locate(piece.end, run); + if (s0 !== null && s1 !== null) return subPath(run, s0, s1); + } + return null; +} + +export const pathLength = (path: Pt[]): number => + path.slice(1).reduce((sum, p, i) => sum + dist(path[i], p), 0); + +const near = (a: Pt, b: Pt): boolean => dist(a, b) <= LINE_TOLERANCE_M; + +/** 조각 길들을 끝끼리 이어 한 고리가 되면 그 넓이(㎡) · 안 닫힘 · 남는 조각 = null(서버 `ring_area`). */ +export function ringArea(paths: Pt[][]): number | null { + if (!paths.length) return null; + const ring = [...paths[0]]; + const rest = paths.slice(1).map((path) => [...path]); + while (rest.length) { + const tail = ring[ring.length - 1]; + const index = rest.findIndex((p) => near(p[0], tail) || near(p[p.length - 1], tail)); + if (index < 0) return null; + const [path] = rest.splice(index, 1); + ring.push(...(near(path[0], tail) ? path : path.reverse()).slice(1)); + } + if (ring.length < 4 || !near(ring[0], ring[ring.length - 1])) return null; + ring.push(ring[0]); // 끝이 허용 안에서만 맞음 — 닫는 변을 꼭 넣음 + let twice = 0; + for (let i = 0; i + 1 < ring.length; i++) + twice += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]; + const area = Math.abs(twice) / 2; + return area > 1e-9 ? area : null; +} + +/** 고른 조각들 값(서버 `cell_value` 조각 갈래) — 길이 합 · 닫힌 넓이 · 못 셈 = null. */ +export function piecesValue( + kind: "length" | "area", + pieces: CrossPickPiece[], + lines: Record, +): number | null { + const paths = pieces.map((piece) => piecePath(piece, lines)); + if (!paths.length || paths.some((path) => path === null)) return null; + const valid = paths as Pt[][]; + return kind === "length" ? valid.reduce((sum, p) => sum + pathLength(p), 0) : ringArea(valid); +} + +/* ── 나눔(화면 몫) ───────────────────────────────────────────────────────── */ + +const cross = (a: Pt, b: Pt): number => a[0] * b[1] - a[1] * b[0]; +const sub = (a: Pt, b: Pt): Pt => [a[0] - b[0], a[1] - b[1]]; + +/** 점이 선분 a→b 위 어디(0~1)에 닿나 — `TOUCH_M` 밖이면 null. */ +function touchAt(point: Pt, a: Pt, b: Pt): number | null { + const d = sub(b, a); + const size2 = d[0] ** 2 + d[1] ** 2; + if (size2 === 0) return null; + const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * d[0] + (point[1] - a[1]) * d[1]) / size2)); + return dist(point, [a[0] + t * d[0], a[1] + t * d[1]]) <= TOUCH_M ? t : null; +} + +/** + * 선들을 서로 만나는 점(교차 · 다른 선 끝이 닿는 점)에서 조각으로 나눔. + * 자르는 자리 = 꺾은선 위치(선분 번호 + 0~1). 같은 선끼리는 안 자름. + * ponytail: 겹쳐 나란한 선분(같은 직선 위)은 교점 없음으로 봄 — 끝 닿음만 자름. + */ +export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] { + const out: SplitPiece[] = []; + lines.forEach((line, li) => { + const cuts: number[] = []; + const pts = line.points; + for (let i = 0; i + 1 < pts.length; i++) { + const p = pts[i]; + const r = sub(pts[i + 1], p); + lines.forEach((other, oi) => { + if (oi === li) return; + const q = other.points; + for (let j = 0; j + 1 < q.length; j++) { + const s = sub(q[j + 1], q[j]); + const denom = cross(r, s); + if (Math.abs(denom) > 1e-12) { + const t = cross(sub(q[j], p), s) / denom; + const u = cross(sub(q[j], p), r) / denom; + if (t >= 0 && t <= 1 && u >= 0 && u <= 1) cuts.push(i + t); + } + } + for (const end of [q[0], q[q.length - 1]]) { + const t = touchAt(end, p, pts[i + 1]); + if (t !== null) cuts.push(i + t); + } + }); + } + const at = (c: number): Pt => { + const i = Math.min(Math.floor(c), pts.length - 2); + const t = c - i; + return [ + pts[i][0] + t * (pts[i + 1][0] - pts[i][0]), + pts[i][1] + t * (pts[i + 1][1] - pts[i][1]), + ]; + }; + const marks = [0, ...cuts.sort((a, b) => a - b), pts.length - 1]; + for (let k = 0; k + 1 < marks.length; k++) { + const [c0, c1] = [marks[k], marks[k + 1]]; + const inner = pts.slice(Math.floor(c0) + 1, Math.ceil(c1)); + const points = [at(c0), ...inner, at(c1)]; + if (pathLength(points) >= MIN_PIECE_M) out.push({ line: line.line, points }); + } + }); + return out; +} + +/** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */ +export function pieceSource(piece: SplitPiece): CrossPickPiece { + const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4]; + return { + line: piece.line, + start: round(piece.points[0]), + end: round(piece.points[piece.points.length - 1]), + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts new file mode 100644 index 000000000..b3e8540ec --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -0,0 +1,245 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Select.ts + * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로 + * 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 · + * Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴): + * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」). + * 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀). + * ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때. + * ========================================================================== */ + +import type { CrossDesign, CrossPickPiece } from "./B06_Section_Api_Types"; +import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; +import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; +import { + DESIGN_LINE_ATTR, + mergeStraightPieces, + pieceLabel, +} from "./B06_Section_UI_Cross_Design_Pick"; +import { + pathLength, + pieceSource, + ringArea, + splitAtCrossings, + type Pt, + type SplitPiece, +} from "./B06_Section_UI_Cross_Detail_Geom"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +export const PIECE_HIT_CLASS = "b06-detail__piece"; + +/** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */ +export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt { + return ([px, py]) => { + const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter; + return [ + axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter, + axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration, + ]; + }; +} + +function mToPx(axes: CrossAxesOptions): (p: Pt) => string { + return ([offset, elevation]) => { + const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration; + const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter; + const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter; + return `${x},${y}`; + }; +} + +function pointsOf(el: Element): number[][] { + if (el.tagName.toLowerCase() === "line") { + const n = (name: string) => Number(el.getAttribute(name)); + return [ + [n("x1"), n("y1")], + [n("x2"), n("y2")], + ]; + } + return (el.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)) + .filter((p) => p.length === 2 && p.every(Number.isFinite)); +} + +/** 그림 도형 층 요소 → 고를 조각(m) — 설계 · 구조물 선은 같은 방향 토막을 직선 하나로 합친 뒤 나눔. */ +export function collectPieces(elements: Element[], axes: CrossAxesOptions): SplitPiece[] { + const toM = pxToM(axes); + const runs: Parameters[0] = []; + const others: SplitPiece[] = []; + for (const el of elements) { + const points = pointsOf(el).map(toM); + if (points.length < 2) continue; + if (el.classList.contains("b06-chart__design-cross")) + runs.push({ + points: points.map(([offset, elevation]) => ({ offset, elevation })), + structure: !el.hasAttribute(DESIGN_LINE_ATTR), + }); + else if (el.classList.contains("b06-chart__cross-profile")) + others.push({ line: "ground", points }); + else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points }); + } + const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({ + line: piece.structure ? "structure" : "design", + points: [ + [piece.from.offset, piece.from.elevation], + [piece.to.offset, piece.to.elevation], + ], + })); + return splitAtCrossings([...straight, ...others]); +} + +/** 고름 바꿈 — 더하기(Shift/Ctrl) = 넣고 빼기 · 아니면 그 하나(다시 누르면 비움). */ +export function toggleSelection(selected: number[], index: number, additive: boolean): number[] { + if (additive) + return selected.includes(index) ? selected.filter((i) => i !== index) : [...selected, index]; + return selected.length === 1 && selected[0] === index ? [] : [index]; +} + +const LINE_NAME: Record = { + design: "설계선", + structure: "구조물 선", + ground: "원지반선", + spoil: "사토장선", +}; + +/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */ +export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPiece[]): string { + if (!pieces.length) return ""; + if (pieces.length === 1) { + const [piece] = pieces; + const from = piece.points[0]; + const to = piece.points[piece.points.length - 1]; + if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil") + return pieceLabel(design, { + from: { offset: from[0], elevation: from[1] }, + to: { offset: to[0], elevation: to[1] }, + structure: piece.line === "structure", + }); + return ( + `${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` + + `수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m` + ); + } + const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0); + const area = ringArea(pieces.map((piece) => piece.points)); + return ( + `${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` + + (area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`) + ); +} + +/** 고른 조각 → 칸 출처(209-1 저장 꼴) — 면적은 닫혀야 · 길이는 하나 이상. */ +export function selectionSource( + kind: "area" | "length", + pieces: SplitPiece[], +): { src: "area" | "length"; pieces: CrossPickPiece[] } | null { + if (!pieces.length) return null; + if (kind === "area" && ringArea(pieces.map((piece) => piece.points)) === null) return null; + return { src: kind, pieces: pieces.map(pieceSource) }; +} + +/* ── 모달 배선 ─────────────────────────────────────────────────────────── */ + +export interface DetailSelect { + /** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(고름 지움). */ + redraw: () => void; + /** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */ + setMode: (kind: "area" | "length" | null) => void; + source: (kind: "area" | "length") => ReturnType; + clear: () => void; + detach: () => void; +} + +export function attachDetailSelect(input: { + cad: HTMLElement; + design: () => CrossDesign | undefined; + axes: () => CrossAxesOptions | null; + /** 조각 누름 뒤(더하기 여부) — 표가 길이 고르기 한 번 누름이면 바로 칸에 넣음. */ + picked: (additive: boolean) => void; + /** [칸에 넣기] 단추. */ + commit: () => void; +}): DetailSelect { + const { cad } = input; + let pieces: SplitPiece[] = []; + let hits: SVGPolylineElement[] = []; + let selected: number[] = []; + let mode: "area" | "length" | null = null; + + const label = document.createElement("span"); + label.className = "b06-cross-card__line-dims b06-detail__measure"; + const put = document.createElement("button"); + put.type = "button"; + put.className = "b06-cross-card__zoom-btn b06-detail__commit"; + put.textContent = "칸에 넣기"; + put.title = "고른 선을 칸에 넣음(Enter)"; + put.addEventListener("click", (event) => { + event.stopPropagation(); + input.commit(); + }); + + const show = (): void => { + const chosen = selected.map((i) => pieces[i]); + hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i))); + label.textContent = selectionLabel(input.design(), chosen); + label.hidden = !chosen.length; + put.hidden = !mode || !selectionSource(mode, chosen); + // [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김 + const bar = cad.querySelector(".b06-cross-card__zoom"); + if (bar && label.parentElement !== bar) bar.prepend(label, put); + }; + + const onClick = (event: MouseEvent): void => { + const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`); + const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area"; + if (!hit) { + if (!additive && !mode && selected.length) { + selected = []; + show(); + } + return; + } + event.stopPropagation(); + selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive); + show(); + input.picked(additive); + }; + cad.addEventListener("click", onClick); + + return { + redraw() { + selected = []; + const axes = input.axes(); + const layer = cad.querySelector("g.b06-chart__plot-layer"); + pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : []; + const toPx = axes ? mToPx(axes) : null; + hits = toPx + ? pieces.map((piece) => { + const hit = document.createElementNS(SVG_NS, "polyline"); + hit.setAttribute("points", piece.points.map(toPx).join(" ")); + hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`); + layer?.append(hit); + return hit; + }) + : []; + show(); + }, + setMode(kind) { + mode = kind; + show(); + }, + source: (kind) => + selectionSource( + kind, + selected.map((i) => pieces[i]), + ), + clear() { + selected = []; + show(); + }, + detach() { + cad.removeEventListener("click", onClick); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts index 4413240e1..24bf4203d 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -6,7 +6,9 @@ * 값은 서버(`B07_DesignDetail_Engine_Cross_Table_Edit`)가 그릴 때마다 다시 셈 — 모달 · B07 도면 표 한 벌. * 조작 직후 그 칸 글만 `localCellValue` 로 즉시 — 짝(① 파이썬 · TS) · 거울 시험 * `resources/tester/test_179_4_detail_table.py`. - * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) · 길이 = 보라 판정 선 두 끝(화면 px → m · 축 옵션). + * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) — + * 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록. + * 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기]. * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; @@ -17,8 +19,10 @@ import type { CrossDetailTable, CrossSection, } from "./B06_Section_Api_Fetch"; +import type { SectionSample } from "./B06_Section_Api_Types"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; -import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; +import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom"; +import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select"; /** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ export const MAX_ROWS = 12; @@ -162,7 +166,6 @@ const AREA_BANDS: ReadonlyArray<[string, string]> = [ ["b06-chart__spoil-fill", "spoil_fill"], ]; export const AREA_PICK_SELECTOR = ".b06-chart__area, .b06-chart__spoil-fill"; -export const LENGTH_PICK_SELECTOR = ".b06-chart__design-hit"; export function areaSourceOf(target: Element): CrossCellSource | null { const hit = target.closest(AREA_PICK_SELECTOR); @@ -170,27 +173,6 @@ export function areaSourceOf(target: Element): CrossCellSource | null { return band ? { src: "area", key: band[1] } : null; } -const round4 = (value: number): number => Math.round(value * 1e4) / 1e4; - -/** 판정 선 두 끝 → [offset, 표고](m) — 도형 층 좌표(카드 `x` · `toDisplayY` 의 역). */ -export function lengthSourceOf(target: Element, axes: CrossAxesOptions): CrossCellSource | null { - const hit = target.closest(LENGTH_PICK_SELECTOR); - const points = (hit?.getAttribute("points") ?? "") - .trim() - .split(/\s+/) - .map((pair) => pair.split(",").map(Number)); - if (points.length < 2 || points.some((p) => p.length !== 2 || !p.every(Number.isFinite))) - return null; - const toM = ([px, py]: number[]): [number, number] => { - const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter; - return [ - round4(axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter), - round4(axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration), - ]; - }; - return { src: "length", start: toM(points[0]), end: toM(points[points.length - 1]) }; -} - /* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */ const AREA_DESIGN: Readonly> = { @@ -219,9 +201,15 @@ function onLine([px, py]: [number, number], line: ReadonlyArray<[number, number] return false; } -/** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null. */ -export function localCellValue(source: CrossCellSource, design: CrossDesign | undefined) { +/** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null. + * `samples` = 측점 원지반(조각 검산 · 209-1). */ +export function localCellValue( + source: CrossCellSource, + design: CrossDesign | undefined, + samples?: ReadonlyArray, +) { const record = (design ?? {}) as unknown as Record; + if ("pieces" in source) return piecesValue(source.src, source.pieces, pickLines(design, samples)); if (source.src === "area") return finite(record[AREA_DESIGN[source.key] ?? ""]); if (source.src !== "length") return null; const line = (design?.design_line ?? []) @@ -292,12 +280,16 @@ export interface DetailTableHost { let menu: ReturnType | null = null; const PICK_HINT = { - area: "면적 띠를 누름 · Esc 취소", - length: "보라 선을 누름 · Esc 취소", + area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소", + length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소", } as const; -/** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. 돌려준 함수 = 떼기(모달 닫힐 때). */ -export function attachDetailTable(host: DetailTableHost): () => void { +/** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. detach = 떼기(모달 닫힐 때) · + * redraw = 그림을 새로 그린 뒤(선 조각 · 판정 선 다시 · 209-1). */ +export function attachDetailTable(host: DetailTableHost): { + detach: () => void; + redraw: () => void; +} { menu ??= createMapContextMenu("b06-detail"); const ctxMenu = menu; if (!ctxMenu.element.isConnected) { @@ -318,9 +310,30 @@ export function attachDetailTable(host: DetailTableHost): () => void { picking = null; delete cad.dataset.pick; delete cad.dataset.pickHint; + select.setMode(null); document.removeEventListener("keydown", onKey, true); }; + /** 고른 출처를 칸에 — 칸 글 즉시(짝 ①) · 세션 초안 → 서버 표 다시 받음. */ + const commit = (source: CrossCellSource | null): void => { + if (!picking || !source) return; + const { key } = picking; + stopPick(); + select.clear(); + const section = host.section(); + const shown = localCellValue(source, section.design, section.samples); + const td = tableBox.querySelector(`td[data-cell="${CSS.escape(key)}"]`); + if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2); + edit((table) => setCell(table, key, source)); + }; + const commitSelection = (): void => { + if (picking) commit(select.source(picking.kind)); + }; const onKey = (event: KeyboardEvent): void => { + if (event.key === "Enter") { + event.preventDefault(); + commitSelection(); + return; + } if (event.key !== "Escape") return; event.stopPropagation(); // 모달은 그대로 — 고르기만 취소 stopPick(); @@ -329,29 +342,30 @@ export function attachDetailTable(host: DetailTableHost): () => void { picking = { kind, key }; cad.dataset.pick = kind; cad.dataset.pickHint = PICK_HINT[kind]; + select.setMode(kind); document.addEventListener("keydown", onKey, true); }; + // 선 조각 고름(209-1) — 길이 한 번 누름(더하기 아님) = 바로 칸에 + const select = attachDetailSelect({ + cad, + design: () => host.section().design, + axes: host.axes, + picked: (additive) => { + if (picking?.kind === "length" && !additive) commitSelection(); + }, + commit: commitSelection, + }); + // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음 const onPick = (event: MouseEvent): void => { - if (!picking) return; - const selector = picking.kind === "area" ? AREA_PICK_SELECTOR : LENGTH_PICK_SELECTOR; - // 겹친 요소(띠 위 판정 선 등) 아래까지 — 그 자리 맨 위 대상 + if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`)) + return; const hit = document .elementsFromPoint(event.clientX, event.clientY) - .find((el) => cad.contains(el) && el.closest(selector)); - const axes = host.axes(); - const source = !hit - ? null - : picking.kind === "area" - ? areaSourceOf(hit) - : axes && lengthSourceOf(hit, axes); + .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); + const source = hit ? areaSourceOf(hit) : null; if (!source) return; event.stopPropagation(); - const { key } = picking; - stopPick(); - const shown = localCellValue(source, host.section().design); - const td = tableBox.querySelector(`td[data-cell="${CSS.escape(key)}"]`); - if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2); - edit((table) => setCell(table, key, source)); + commit(source); }; cad.addEventListener("click", onPick); @@ -401,12 +415,16 @@ export function attachDetailTable(host: DetailTableHost): () => void { tableBox.addEventListener("focusout", onBlur); tableBox.addEventListener("keydown", onEnter); - return () => { - stopPick(); - ctxMenu.close(); - cad.removeEventListener("click", onPick); - tableBox.removeEventListener("contextmenu", onMenu); - tableBox.removeEventListener("focusout", onBlur); - tableBox.removeEventListener("keydown", onEnter); + return { + detach: () => { + stopPick(); + ctxMenu.close(); + select.detach(); + cad.removeEventListener("click", onPick); + tableBox.removeEventListener("contextmenu", onMenu); + tableBox.removeEventListener("focusout", onBlur); + tableBox.removeEventListener("keydown", onEnter); + }, + redraw: () => select.redraw(), }; } diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index 2ceacd67b..0ab8bcb08 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -41,10 +41,13 @@ function rows(warnings: FillSlopeWarning[], stationInterval: number): NoticeChip }); } -export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { +export function createFillSlopeNotice( + onPick: (stationId: string) => void, + selected: () => string | null, +): FillSlopeNotice { const root = document.createElement("div"); root.className = "b06-section__notice-bar"; - const chips = createNoticeChips(onPick); + const chips = createNoticeChips(onPick, selected); root.append(chips.root); return { diff --git a/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts index 648236959..54760e806 100644 --- a/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts +++ b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts @@ -3,6 +3,8 @@ * 횡단 알림 줄 칩 꼴(196-1) — 항목 = 짧은 이름 + 측점 수 칩 한 줄 · 긴 설명 = 공용 호버. * 줄 아래 내용 칸 하나를 같이 씀 — 누른 칩의 측점 목록만 · 같은 칩 다시 누르면 닫힘 · * 다른 칩 누르면 바뀜(한 번에 하나). 측점 단추 누름 = `onPick`(그 카드로). + * ⭐ 207-1 — 내용 칸은 줄 아래 카드 위에 겹쳐 뜸(흐름 밖 · CSS) · 측점 누르면 접힘 · + * 열 때 지금 고른 측점(`selected`) 단추 강조. * 무엇을 셀지는 부르는 쪽(`_FillSlope_Notice`) — 여기는 그리기만. * ========================================================================== */ @@ -29,7 +31,10 @@ export interface NoticeChips { update: (items: NoticeChipItem[]) => void; } -export function createNoticeChips(onPick: (stationId: string) => void): NoticeChips { +export function createNoticeChips( + onPick: (stationId: string) => void, + selected: () => string | null = () => null, +): NoticeChips { const root = document.createElement("div"); root.className = "b06-section__notice-blocks"; const chips = document.createElement("div"); @@ -57,12 +62,14 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh ); panel.hidden = !open; panel.classList.toggle("is-info", open?.tone === "info"); + const picked = open ? selected() : null; panel.replaceChildren( ...(open?.rows ?? []).map((row) => { const button = document.createElement("button"); button.type = "button"; button.textContent = row.text; button.dataset.stationId = row.stationId; + button.classList.toggle("is-selected", row.stationId === picked); return button; }), ); @@ -77,7 +84,10 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh }); panel.addEventListener("click", (event) => { const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; - if (id) onPick(id); + if (!id) return; + openKey = null; + render(); + onPick(id); }); attachHoverInfo( chips, diff --git a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts index 08da4506a..6833d8a20 100644 --- a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts +++ b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts @@ -80,7 +80,9 @@ if (typeof document !== "undefined") document.addEventListener( "pointerdown", (event) => { - lastPointer = event.target instanceof Element ? event.target : null; + // 새 창 종단도가 넘긴 누름은 다른 창 요소라 instanceof Element 가 거짓 — closest 로 가림(219-1) + const target = event.target as Element | null; + lastPointer = typeof target?.closest === "function" ? target : null; }, true, ); @@ -90,6 +92,14 @@ export function arrowsBelongToProfile(): boolean { return !!lastPointer?.closest(".b05-route-profile"); } +/** 조정창 없는 고름(관 · 도수로/돌붙임 띠)이 방향키를 삼키나 — 그 동안 방향키는 아무것도 안 옮김. + * 안 삼키면 종단도 듣개가 받아 관 기준측점을 0.1 m 씩 몰래 옮겼음(219-1). 종단도를 눌렀으면 종단도 몫. */ +export function swallowsArrows(side: string | undefined, key: string): boolean { + return ( + (side === "pipe" || side === "chute") && key.startsWith("Arrow") && !arrowsBelongToProfile() + ); +} + /** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */ const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active'; diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index b5549919b..0dec26028 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -21,6 +21,7 @@ import type { SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire"; import { L } from "./B06_Section_UI_Section_Common"; import { arrowsBelongToProfile, + swallowsArrows, bindDragHandle, dragReadout, makeButton, @@ -437,6 +438,14 @@ export function buildStructurePanel( document.removeEventListener("keydown", onArrowKey); return; } + // 관 · 도수로 띠 고름(조정창 없음) = 방향키 삼킴 · 보기가 보일 때만(219-1) + if ( + swallowsArrows(root.dataset.side, event.key) && + root.parentElement?.getClientRects().length + ) { + event.preventDefault(); + return; + } const slot = ARROW_SLOT[event.key]; // 보기가 숨었으면(한 페이지 두 보기 · 계획서 24-3a) 다른 보기의 방향키를 먹지 않는다. if (!slot || root.classList.contains("is-hidden") || !root.getClientRects().length) return; @@ -503,7 +512,10 @@ export function buildStructurePanel( root.dataset.side = pick === "pipe" || pick === "chute" ? pick : ""; document.removeEventListener("keydown", onArrowKey); drag.detach(); - if (!key) return; + if (!key) { + if (!opts?.dock && root.dataset.side) document.addEventListener("keydown", onArrowKey); + return; + } if (!opts?.dock) { document.addEventListener("keydown", onArrowKey); drag.attach(); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 0abc4bfc5..e3c76208c 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -38,6 +38,7 @@ import { } from "../common_util/common_util_summary_item"; import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { + coversAtStation, coversChainage, NO_SELECTION, PIPE_MATCH_M, @@ -163,7 +164,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc let markTypes: StructureType[] = []; /** 카드 누름이 덮어 여는 구조물 — 노면공(G군)은 뺌(152-5 · 목록 · 알약 고름은 id 길로 그대로). */ const coverable = (entry: StructureInstance): boolean => - markTypes.find((type) => type.type_id === entry.type_id)?.group !== "G"; + coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group); /** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */ let preferredId: string | null = null; /** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */ @@ -475,7 +476,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); - if (hit) hit.chainage_m = to; + if (hit) { + // 그 관 측점의 계획고 편집도 새 자리로 — 옛 키가 유령 꼭짓점으로 남지 않게(213-2) + deps.shared.profile?.resetStationEdit(hit.chainage_m, to); + hit.chainage_m = to; + } if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M) currentChainageM = to; pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index bfda291de..105bcddb9 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -106,6 +106,8 @@ interface StaleCandidate { geometry_preset?: string; two_stage_slope?: boolean; surface_drop_m?: number; + section_mode?: string; + ditch_enabled?: boolean; } | null; } @@ -120,6 +122,8 @@ function isStaleSection( // 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의 // 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다. if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; + // 옛 엔진이 양측 성토에 측구를 세워 둔 저장분(206-1) — 지금 엔진으로 다시 셈. + if (design.section_mode === "both_fill" && design.ditch_enabled === true) return true; if (!profiles?.length) return false; const planned = designElevationAt(profiles, section.chainage_m); if (planned === undefined) return false; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index a67009b38..bc95835f8 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -124,8 +124,9 @@ export function createSectionView( } = { balloonScope: "-" }; // 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 카드 위 · 측점 누르면 그 카드로. - const fillSlopeNotice = createFillSlopeNotice((id) => - selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true), + const fillSlopeNotice = createFillSlopeNotice( + (id) => (selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true)), + () => selectedStationId, ); // 열 수 칸(138-3) — 고정 알림 줄 오른쪽 끝 · 1~6 · 바꾸면 카드 전부 다시 그림 const columnsField = createInputField({ diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 8a0d5fdd7..6bd6c56e3 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -124,7 +124,8 @@ } /* 알림 줄(138-3) — 카드 칸 맨 위 고정 · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸. - 위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게 */ + 위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게. + 217-1 — 칩 줄 위 여백 16→8 · 칩 줄 ↔ 첫 카드 28→16(카드 칸 gap 24 에서 8 을 당김) */ .b06-section__notice-bar { position: sticky; top: calc(-1 * var(--spacing-16)); @@ -132,8 +133,8 @@ display: flex; align-items: flex-start; gap: var(--spacing-8); - margin-top: calc(-1 * var(--spacing-16)); - padding-block: var(--spacing-16) var(--spacing-4); + margin-block: calc(-1 * var(--spacing-16)) calc(-1 * var(--spacing-8)); + padding-block: var(--spacing-8) 0; background: var(--color-bg); } @@ -172,9 +173,10 @@ } .b06-section__notice-chip { - padding: 0 var(--spacing-8); + padding: 2px var(--spacing-8); border: 1px solid currentColor; - border-radius: var(--radius-sm); + /* 둥근 사각(207-1) — 공용 단추 반경 · 알약 아님 */ + border-radius: var(--radius-buttons); background: transparent; color: var(--color-warning); font-size: var(--text-caption); @@ -193,14 +195,24 @@ font-weight: 600; } -/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 줄 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게 */ +/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 칸 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게. + 207-1 — 줄 아래 카드 위에 겹쳐 뜸(흐름 밖) · 열고 닫아도 줄 높이 · 카드 자리 그대로 */ .b06-section__notice-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1; display: flex; flex-wrap: wrap; gap: var(--spacing-4); max-height: 30vh; overflow: auto; - padding-top: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); color: var(--color-warning); } @@ -213,6 +225,13 @@ white-space: nowrap; } +/* 지금 고른 측점(207-1) — 카드 고름 색과 같음 */ +.b06-section__notice-list > button.is-selected { + outline: 2px solid var(--color-danger); + outline-offset: -1px; + font-weight: 600; +} + .b06-cross-card { cursor: pointer; transition: diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index ab0354901..2f14f0637 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -164,11 +164,42 @@ /* 그래프에서 고르기 — 고를 대상만 손가락 · 왼쪽 아래(눈금 자리) 안내 글 */ .b06-detail__cad[data-pick="area"] .b06-chart__area > polygon, -.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill, -.b06-detail__cad[data-pick="length"] .b06-chart__design-hit { +.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill { cursor: pointer; } +/* 선 조각 판정 선(209-1) — 투명 굵은 선 · 배율 무관 굵기 · 고른 조각 = 붉은 띠 */ +.b06-detail__piece { + fill: none; + stroke: transparent; + stroke-width: 10; + stroke-linecap: round; + vector-effect: non-scaling-stroke; + pointer-events: stroke; + cursor: pointer; +} + +.b06-detail__piece:hover { + stroke: color-mix(in srgb, var(--color-accent) 25%, transparent); +} + +.b06-detail__piece.is-selected { + stroke: color-mix(in srgb, var(--color-danger) 55%, transparent); + stroke-width: 6; +} + +/* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */ +.b06-detail__commit { + width: auto; + padding: 0 var(--spacing-4); + color: var(--color-accent); + white-space: nowrap; +} + +.b06-detail__commit[hidden] { + display: none; +} + .b06-detail__cad[data-pick]::after { content: attr(data-pick-hint); position: absolute; diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 696c3f773..2efd5bcc4 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -41,6 +41,8 @@ export interface CadDrawing { stale_layout?: boolean; /** 횡단 장 — 지금과 다른 표 · 글 배율로 확정된 저장본(180-2). */ stale_scale?: boolean; + /** 횡단 장 — 측점 도면층(212-2) 앞에 확정된 저장본. [수정]을 누르면 측점 층으로 다시 만든다. */ + stale_layers?: boolean; layers: { id: string; name: string; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index e2b8d5eb5..1dc94c77b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -5,6 +5,8 @@ 칸 가운데 · 작성 척도 1/100 그대로(큰 단면이 칸을 넘으면 겹침 — 사용자 배치로 고침). 칸 = 인쇄되는 표 격자선 · 블록 테두리 없음 · 칸보다 큰 단면은 `overflow_cells`(169-2). 칸 안 = 표를 칸 아래 가운데에 먼저 · 그 위 글 · 그림(도로 중심 = 칸 가운데)(174-1). +측점마다 도면층 하나(「횡단 No.3」) = 원지반 · 계획선 · 구조물 · 암 그림 · 종류는 색 · 선 꼴 +· 표 · 글은 수량표 층(212-2). """ import re @@ -52,6 +54,10 @@ CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?") _BORDER_PAD_MM = CROSS_BORDER_PAD_MM # 칸 아래선 ~ 표 아래선 여백(mm) — 같은 행 표가 한 높이(174-1) CELL_TABLE_PAD_MM = 5.0 +# 측점 도면층 id 머리(212-2) — 뒤에 측점 · DXF 층 이름도 이 id +STATION_LAYER_PREFIX = "b08-station-" +# 측점 층으로 옮길 그림 종류 층 — 표 · 글(수량표 층)은 그대로 +_DRAWING_LAYER_IDS = {GROUND_LAYER_ID, DESIGN_LAYER_ID, STRUCTURE_LAYER_ID, ROCK_LAYER_ID} def _design_points(design_line: list[Any] | None) -> list[tuple[float, float]]: @@ -178,11 +184,20 @@ def _grid_lines(sheet_id: str, cols: int, rows: int) -> list[dict[str, Any]]: ] +def _move_to_layer(entity: dict[str, Any], layer_id: str) -> None: + """그림 종류 층 개체(자식 선까지)를 측점 층으로 — id 는 그대로(212-2).""" + if entity.get("layerId") in _DRAWING_LAYER_IDS: + entity["layerId"] = layer_id + for child in entity.get("children") or []: + child["layerId"] = layer_id + + def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> dict[str, Any]: """한 장을 만든다. sections는 이 장에 담을 측점 입력들(배치 순서대로).""" entities: list[dict[str, Any]] = [] placements: list[dict[str, Any]] = [] overflow: list[list[float]] = [] + station_layers: list[dict[str, Any]] = [] slots = sheet.get("slots") or [] cols, rows = sheet.get("grid") or (4, 4) usable_w, usable_h = usable_area() @@ -222,8 +237,17 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> group_id = str(uuid5(_ENTITY_NS, f"{seed_id}:group")) for entity in placed["entities"]: entity["groupId"] = group_id + # 측점 도면층(212-2) — 층 관리에서 그 측점 그림만 고르기 · 켜고 끄기 · 잠그기 + layer_id = f"{STATION_LAYER_PREFIX}{section['chainage']}" + title = section.get("title") or f"{section['chainage']}" + station_layers.append(_layer(layer_id, f"횡단 {title}")) + design_ids = [e["id"] for e in placed["entities"] if e["layerId"] == DESIGN_LAYER_ID] + for entity in placed["entities"]: + _move_to_layer(entity, layer_id) for placement in placed.get("cross_placements") or []: placement["group_id"] = group_id + placement["layer_id"] = layer_id + placement["design_ids"] = design_ids # 화면이 다시 그려 갈아 끼울 서버 설계선 entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or []) @@ -246,10 +270,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> # 칸보다 큰 단면의 칸 [x0, y0, x1, y1] — 라우터가 CAD 가이드 붉은 틀로 보냄(169-2) "overflow_cells": overflow, "layers": [ - _layer(GROUND_LAYER_ID, "원지반"), - _layer(DESIGN_LAYER_ID, "계획선"), - _layer(STRUCTURE_LAYER_ID, "구조물"), - _layer(ROCK_LAYER_ID, "암 경계선"), + *station_layers, _layer(CROSS_TABLE_LAYER_ID, "수량 산출표"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py index bcdb964c2..d522ab3f9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py @@ -6,11 +6,17 @@ area = 설계 면적 칸 그대로(`AREA_KEYS` · 띠 키 = 표 키) length = 두 끝(m · offset · 표고)이 설계선 위면 hypot · 아니면 비움 → 「다시 고름」 text = 글 그대로 — 더한 칸(`r:` 행 · `c:` 열)만 + 고른 조각(209-1 · `pieces` = [{line, start, end}]) — 교차점에서 나눈 선 조각 목록: + length = 조각 길이 합 · area = 조각이 닫힌 고리를 이루면 그 넓이(안 닫힘 → 「다시 고름」) + 조각 길 = 그 선(설계선 · 원지반선 · 사토장선) 위 두 끝 사이 꺾은선 + (두 끝이 선 밖 → 「다시 고름」) + · 구조물 선(`structure` · 브라우저 기하)은 위치 검산 없이 두 끝 직선. 행(`rows`) = 묶음 안(이 행 아래 · 묶음이면 하위 줄) 또는 끝에 항목 줄(좌 · 중 · 우 이름 + 값). 열(`columns`) = 오른쪽 끝 · 모든 행(비고 등). 짝(① 파이썬 · TS) = `B06_Section/B06_Section_UI_Cross_Detail_Table.ts` `localCellValue` -(조작 중 즉시 표시) · 거울 시험 `resources/tester/test_179_4_detail_table.py`. +(조작 중 즉시 표시) · 조각 길 · 넓이 = `B06_Section/B06_Section_UI_Cross_Detail_Geom.ts` · +거울 시험 `resources/tester/test_179_4_detail_table.py` · `test_209_1_detail_select.py`. """ from __future__ import annotations @@ -46,9 +52,9 @@ def _point(value: Any) -> tuple[float, float] | None: return (x, y) if x is not None and y is not None else None -def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]: +def _polyline(raw: Any) -> list[tuple[float, float]]: points = [] - for point in design.get("design_line") or []: + for point in raw if isinstance(raw, list) else []: if isinstance(point, dict): x, y = _num(point.get("offset_m")), _num(point.get("elevation_m")) if x is not None and y is not None: @@ -56,6 +62,24 @@ def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]: return points +def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]: + return _polyline(design.get("design_line")) + + +def ground_runs(samples: Any) -> list[list[tuple[float, float]]]: + """원지반선 — 무효 표고 샘플마다 끊은 꺾은선들(카드 `appendGroundProfile` 과 같은 규칙).""" + runs: list[list[tuple[float, float]]] = [[]] + for sample in samples if isinstance(samples, list) else []: + sample = sample if isinstance(sample, dict) else {} + x = _num(sample.get("offset_m")) + y = _num(sample.get("elevation_m", sample.get("z"))) + if sample.get("valid") is False or x is None or y is None: + runs.append([]) + else: + runs[-1].append((x, y)) + return [run for run in runs if len(run) > 1] + + def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> bool: """점이 설계선(꺾은선) 조각 하나에서 `LINE_TOLERANCE_M` 안인가.""" px, py = point @@ -68,12 +92,119 @@ def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> boo return False +Point = tuple[float, float] + + +def _locate(point: Point, run: list[Point]) -> float | None: + """선 위 자리(앞 끝에서 잰 길이 m) — 허용 안 가장 가까운 곳 · 없으면 None.""" + best: tuple[float, float] | None = None + along = 0.0 + for (ax, ay), (bx, by) in zip(run, run[1:]): + dx, dy = bx - ax, by - ay + size = hypot(dx, dy) + t = 0.0 if size == 0 else ((point[0] - ax) * dx + (point[1] - ay) * dy) / size**2 + t = max(0.0, min(1.0, t)) + gap = hypot(point[0] - (ax + t * dx), point[1] - (ay + t * dy)) + if gap <= LINE_TOLERANCE_M and (best is None or gap < best[0]): + best = (gap, along + t * size) + along += size + return best[1] if best else None + + +def _at(run: list[Point], s: float) -> Point: + """선 위 자리 s(m) 의 점.""" + for (ax, ay), (bx, by) in zip(run, run[1:]): + size = hypot(bx - ax, by - ay) + if s <= size: + t = 0.0 if size == 0 else s / size + return (ax + t * (bx - ax), ay + t * (by - ay)) + s -= size + return run[-1] + + +def sub_path(run: list[Point], s0: float, s1: float) -> list[Point]: + """선 위 두 자리 사이 꺾은선(s0 → s1 차례) — 사이 꼭짓점 포함.""" + lo, hi = min(s0, s1), max(s0, s1) + inner: list[Point] = [] + along = 0.0 + for a, b in zip(run, run[1:]): + along += hypot(b[0] - a[0], b[1] - a[1]) + if lo < along < hi: + inner.append(b) + path = [_at(run, lo), *inner, _at(run, hi)] + return path if s0 <= s1 else path[::-1] + + +def piece_path(piece: Any, lines: dict[str, list[list[Point]]]) -> list[Point] | None: + """고른 조각 하나 → 그 선 위 꺾은선 · 두 끝이 선 밖이면 None(구조물 선 = 두 끝 직선).""" + if not isinstance(piece, dict): + return None + start, end = _point(piece.get("start")), _point(piece.get("end")) + if not start or not end: + return None + if piece.get("line") == "structure": + return [start, end] + for run in lines.get(str(piece.get("line")), []): + s0, s1 = _locate(start, run), _locate(end, run) + if s0 is not None and s1 is not None: + return sub_path(run, s0, s1) + return None + + +def path_length(path: list[Point]) -> float: + return sum(hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(path, path[1:])) + + +def _near(a: Point, b: Point) -> bool: + return hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M + + +def ring_area(paths: list[list[Point]]) -> float | None: + """조각 길들을 끝끼리 이어 한 고리가 되면 그 넓이(㎡) · 안 닫힘 · 남는 조각 = None.""" + if not paths: + return None + ring = list(paths[0]) + rest = [list(path) for path in paths[1:]] + while rest: + index = next( + (i for i, p in enumerate(rest) if _near(p[0], ring[-1]) or _near(p[-1], ring[-1])), + None, + ) + if index is None: + return None + path = rest.pop(index) + ring += (path if _near(path[0], ring[-1]) else path[::-1])[1:] + if len(ring) < 4 or not _near(ring[0], ring[-1]): + return None + ring.append(ring[0]) # 끝이 허용 안에서만 맞음 — 닫는 변을 꼭 넣음 + area = abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(ring, ring[1:]))) / 2 + return area if area > 1e-9 else None + + +def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]: + """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선).""" + return { + "design": [_design_line(design)], + "ground": ground_runs(samples), + "spoil": [_polyline(design.get("spoil_fill_line"))], + } + + def cell_value( - source: dict[str, Any], design: dict[str, Any] | None + source: dict[str, Any], design: dict[str, Any] | None, samples: Any = None ) -> tuple[float | None, str | None, bool]: - """출처 하나 → (숫자, 글, 다시 고름).""" + """출처 하나 → (숫자, 글, 다시 고름). `samples` = 측점 원지반 샘플(조각 검산).""" design = design if isinstance(design, dict) else {} kind = source.get("src") + pieces = source.get("pieces") + if kind in ("length", "area") and isinstance(pieces, list): + lines = pick_lines(design, samples) + paths = [piece_path(piece, lines) for piece in pieces] + valid = [path for path in paths if path is not None] + if not valid or len(valid) != len(paths): + return None, None, True + number = sum(map(path_length, valid)) if kind == "length" else ring_area(valid) + return number, None, number is None if kind == "area": return _num(design.get(_AREA_DESIGN.get(str(source.get("key")), ""))), None, False if kind == "length": @@ -89,9 +220,9 @@ def cell_value( def edited_cells( - design: dict[str, Any] | None, + design: dict[str, Any] | None, samples: Any = None ) -> tuple[dict[str, float | None], dict[str, str], list[str]]: - """patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만.""" + """patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만 · `samples` = 원지반.""" values: dict[str, float | None] = {} texts: dict[str, str] = {} repick: list[str] = [] @@ -99,7 +230,7 @@ def edited_cells( for key, source in (cells if isinstance(cells, dict) else {}).items(): if not isinstance(source, dict): continue - number, text, again = cell_value(source, design) + number, text, again = cell_value(source, design, samples) if text is not None: if key[:2] in ("r:", "c:"): texts[key] = text diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b63a43981..a351e69c7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -18,6 +18,7 @@ from B05_Profile.B05_Profile_Engine_Sections import ( from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, + GROUND_LAYER_ID, build_cross_drawing, infer_station_interval, station_no_label, @@ -32,6 +33,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_hau from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, + STATION_LAYER_PREFIX, build_cross_sheet, plan_cross_sheets, ) @@ -313,7 +315,7 @@ def _quantity_table( for key in QUANTITY_VALUE_KEYS: table.setdefault(key, num(quantities.get(key))) # [상세] 표 손질(179-4) — 고른 칸 · 더한 칸을 서버가 다시 셈(모달 · B07 도면 한 벌) - table.update(edited_cells(design)[0]) + table.update(edited_cells(design, source.get("samples"))[0]) return table @@ -462,12 +464,22 @@ def _read_drawing( else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) + layers = [layer for layer in saved.get("layers") or [] if isinstance(layer, dict)] + if kind == "cross": + # 180-1 앞 확정본 = 원지반 층 잠금째 저장 — 열 때 풂(212-1) + for layer in layers: + if layer.get("id") == GROUND_LAYER_ID: + layer["isLocked"] = False if kind == "cross" and manifest_entry.get("layout") != CROSS_SHEET_LAYOUT: saved["stale_layout"] = True # 화면이 [수정] → 새 배치로 다시 만들기를 안내 elif kind == "cross" and drawing_id.startswith("cross_s"): scale = read_cross_layout(project_root)["table_scale"] if manifest_entry.get("table_scale", 1.0) != scale: saved["stale_scale"] = True # 옛 배율로 확정한 장 — 그대로 두고 안내(180-2) + elif not any( + str(layer.get("id")).startswith(STATION_LAYER_PREFIX) for layer in layers + ): + saved["stale_layers"] = True # 측점 층 앞 확정본 — [수정] 때 측점 층(212-2) stored_table = manifest_entry.get("quantity_table") table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None return kind, label, saved, True, table diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index f0e55d6ba..c4e446c65 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -34,6 +34,16 @@ export interface CrossPlacement { frame?: number[]; /** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */ group_id?: string; + /** 측점 도면층(212-2) — 덧그림도 그 층에. */ + layer_id?: string; + /** 갈아 끼울 서버 설계선 id(212-2) — 측점 층이면 층으로는 못 가림. */ + design_ids?: string[]; +} + +/** 개체(자식 선까지)를 측점 층으로(212-2). */ +function moveToLayer(entity: Entity, layerId: string): void { + entity.layerId = layerId; + for (const child of (entity.children as Entity[] | undefined) ?? []) moveToLayer(child, layerId); } /** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ @@ -62,6 +72,7 @@ export async function appendStructureEntities( let added = 0; const fresh: Entity[] = []; const replaced: number[][] = []; + const designIds = new Set(); for (const placement of placements) { const section = sections.find( (item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M, @@ -81,6 +92,8 @@ export async function appendStructureEntities( } if (!entities.length) continue; if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id; + if (placement.layer_id) for (const entity of entities) moveToLayer(entity, placement.layer_id); + for (const id of placement.design_ids ?? []) designIds.add(id); // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. replaced.push(frame); fresh.push(...entities); @@ -89,7 +102,7 @@ export async function appendStructureEntities( if (replaced.length) { // 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다. drawing.entities = drawing.entities.filter((entity) => { - if (entity.layerId !== DESIGN_LAYER_ID) return true; + if (entity.layerId !== DESIGN_LAYER_ID && !designIds.has(entity.id as string)) return true; const box = entityBox(entity); if (!box) return true; return !replaced.some( diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 22499e06f..4ee7d0a34 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -365,6 +365,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { "옛 표 배율로 확정된 장입니다. [수정]을 누르면 지금 배율로 다시 만듭니다.", "info", ); + } else if (response.drawing.stale_layers) { + showToast( + "측점 도면층 앞에 확정된 장입니다. [수정]을 누르면 측점마다 층(횡단 No.)으로 다시 만듭니다.", + "info", + ); } } catch (error) { cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 0f214f7c5..68426a2fa 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -185,8 +185,9 @@ export interface CrossLayoutExtras { } /** - * 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」 + 「표 배율 [1] [적용]」. - * `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔. + * 횡단 장 배치 칸(140-3 · 153-1) — 줄마다 한 일(212-3): + * 「배치 열 [4] 행 [4]」 · 「[이 장] [모든 장]」 · 「표 배율 [1] [배율 적용]」 · (부르는 쪽이 붙이는 [안내선 숨기기]). + * 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*`) · `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔. */ export function buildCrossLayoutControls( grid: [number, number], @@ -196,6 +197,12 @@ export function buildCrossLayoutControls( ): HTMLElement { const box = document.createElement("div"); box.className = "b07-cross-layout"; + const row = (...items: HTMLElement[]): HTMLElement => { + const line = document.createElement("div"); + line.className = "b07-cross-layout__row"; + line.append(...items); + return line; + }; const number = (value: number, label: string) => createInputField({ label, @@ -227,10 +234,11 @@ export function buildCrossLayoutControls( return button; }; box.append( - cols.root, - rows.root, - action("이 장", "이 장만 이 배치로", onApply), - action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), + row(cols.root, rows.root), + row( + action("이 장", "이 장만 이 배치로", onApply), + action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), + ), ); if (extras.scale) { const { onApply: applyScale } = extras.scale; @@ -255,7 +263,7 @@ export function buildCrossLayoutControls( }, }); button.title = "모든 장의 표 · 표 위 글 크기(기본 1)"; - box.append(scale.root, button); + box.append(row(scale.root, button)); } return box; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index e99f64b41..71c501fb3 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -225,8 +225,9 @@ color: var(--color-text-muted); } -/* 횡단 장 배치(153-1) — 횡단도 묶음 맨 아래 전체 폭: 배치 열 [4] 행 [4] [이 장] [모든 장]. - 좌측이 182px 까지 줄어드니 줄바꿈 허용 · 공용 칸의 오류 자리는 접음. */ +/* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일: + 배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기]. + 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비 · 오류 자리는 접음. */ .b07-cross-layout-slot { grid-column: 1 / -1; } @@ -237,25 +238,45 @@ .b07-cross-layout { display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-4) var(--spacing-8); + flex-direction: column; + gap: var(--spacing-4); } -.b07-cross-layout > .ui-field { +.b07-cross-layout__row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.b07-cross-layout__row > * { + flex: 1 1 0; + min-width: 0; +} + +.b07-cross-layout .ui-field { display: flex; flex-direction: row; align-items: center; gap: var(--spacing-4); } +.b07-cross-layout .ui-btn, +.b07-cross-layout .ui-field .ui-input { + height: auto; + padding: var(--btn-bar-padding); + font-size: var(--btn-bar-font-size); + line-height: 1; +} + .b07-cross-layout .ui-field__label { margin: 0; + font-size: var(--btn-bar-font-size); white-space: nowrap; } -.b07-cross-layout .ui-input { - width: 48px; +.b07-cross-layout .ui-field .ui-input { + width: 100%; + min-width: 0; } .b07-cross-layout .ui-field__error { diff --git a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx index 6b0b707ae..c5c16e0c2 100644 --- a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx +++ b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx @@ -1,13 +1,30 @@ -/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) */ +/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */ import type { FC } from 'react'; import { runCommandInput } from '../commands/run-command'; -import { getScreenCanvasDrawController } from '../state'; +import { + getScreenCanvasDrawController, + isDrawingReadOnly, + isHostSave, + isViewOnly, +} from '../state'; export const ViewControls: FC = () => { const zoom = getScreenCanvasDrawController()?.getScreenScale() ?? 1; + // 제목줄(빠른 막대)이 없는 M02 양식 · 구조물 카드 — 고칠 수 있을 때만 + const showUndo = isHostSave() && !isViewOnly() && !isDrawingReadOnly(); return (
+ {showUndo && ( + <> + + + + )}