diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index cc0b3335e..8b2b0f640 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -453,7 +453,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) => { 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/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 043b0094a..a2c856a30 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -27,6 +27,8 @@ 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"; /** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ const KEY_STEP_M = 0.1; @@ -112,6 +114,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(); @@ -144,12 +158,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") { @@ -284,6 +305,12 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. if (target && target.closest("input, select, textarea")) return; const key = event.key; + // [자유지정] 중 Enter 끝 · Esc 취소 · Backspace 점 빼기 — 단추 초점의 Enter 누름도 막는다. + if (tools.mode() === "free" && free.key(key)) { + event.preventDefault(); + freeView.redraw(); + return; + } if (isCtrlKey(event, "z")) { event.preventDefault(); flushHold(); 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_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_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 494c58df0..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); } 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_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 8932f7cd2..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, ) @@ -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/resources/tester/helper_215_1_free_profile.cjs b/resources/tester/helper_215_1_free_profile.cjs new file mode 100644 index 000000000..7944663cf --- /dev/null +++ b/resources/tester/helper_215_1_free_profile.cjs @@ -0,0 +1,175 @@ +/* 215-1 종단 [자유지정] 단위검증 헬퍼 — `design_profile_free.ts` 를 그 자리에서 트랜스파일해 + * 범위 · 시작 이음 · 세로선 붙음 · 높이 변환 · 미리 보기 · 기울기 판정 · 종단곡선 · 끝 이음 · + * Esc · Backspace · 적용 편집을 Node 로 돌린다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const free = loadTs(path.join(ROOT, "A00_Common", "design", "design_profile_free.ts")); +const align = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Alignment.ts")); + +// 전체 측점 폴리라인(20m 간격 · 0~200m) — 계획고 = 지반고, 마디마다 기울기가 다르다. +const stations = Array.from({ length: 11 }, (_, i) => i * 20); +const ground = stations.map((s, i) => 100 + 0.3 * i + (i % 2 ? 0.4 : 0)); +const base = { + policy: { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 20, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, + }, + onlyExplicitCurves: true, + basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })), + chainage: stations, + ground, + stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })), +}; + +function harness() { + let edits = align.emptyEdits(); + const log = { applied: [], closed: 0, changed: 0 }; + const draw = free.createFreeDraw({ + base: () => base, + alignment: () => align.buildAlignment(base, edits), + edits: () => edits, + apply: (next) => { + log.applied.push(next); + edits = next; + }, + close: () => (log.closed += 1), + changed: () => (log.changed += 1), + }); + return { draw, log, edits: () => edits }; +} + +const out = {}; +const z0 = (s) => ground[stations.indexOf(s)]; + +// ① 높이 변환 — 세로 창(기준 + 지금 변환) 왕복 · 변환 글 읽기. +const view = { center: 100, pxPerM: 10, zero: 150, scale: 1.2, offset: -20 }; +out.yAt103_2 = free.chartYAtElevation(view, 103.2); +out.zBack = free.elevationAtChartY(view, out.yAt103_2); +out.parsed = free.parseWindowTransform("translate(0 -20) scale(1 1.2)"); +out.parsedNone = free.parseWindowTransform(null); + +// ② 세로선 붙음 — 가장 가까운 측점 · 범위 안만. +out.snap79 = free.snapStation(stations, 79); +out.snapOutside = free.snapStation(stations, 190, [40, 160]); + +// ③ 범위 → 시작 이음 → 점 → 미리 보기 → 끝 이음. +{ + const { draw, log, edits } = harness(); + draw.click(41, 0, stations); // 범위 첫 세로선(40) + out.pendingEdges = draw.marks().edges; + draw.click(158, 0, stations); // 범위 끝(160) + out.rangePhase = draw.phase(); + out.band = draw.marks().band; + draw.click(100, 105, stations); // 범위 가운데 — 시작 아님 + out.stillArmed = draw.phase(); + draw.click(43, 999, stations); // 시작 세로선 근처 — 그 측점 계획고에 이어 시작 + out.startPhase = draw.phase(); + out.startDot = draw.marks().dots[0]; + draw.click(79, 103.456, stations); // 80 에 붙고 높이는 클릭 높이(0.01 단위) + out.point1 = draw.marks().dots[1]; + draw.click(61, 90, stations); // 뒤로 가는 클릭 — 무시 + out.dotsAfterBack = draw.marks().dots.length; + draw.hover({ chainageM: 121, elevationM: 101.2, stations }); + out.preview = draw.marks().segments.map((s) => ({ + to: s.to, + grade: Number(s.grade_pct.toFixed(4)), + over: s.over, + preview: s.preview, + })); + draw.hover({ chainageM: 121, elevationM: 80, stations }); // 기울기 넘음 + const steep = draw.marks().segments.at(-1); + out.steep = { grade: Number(steep.grade_pct.toFixed(2)), over: steep.over }; + draw.click(121, 101.2, stations); + draw.hover({ chainageM: 157, elevationM: 50, stations }); // 끝 세로선 → 그 계획고로 이음 + out.finishPreview = draw.marks().segments.at(-1).to; + out.hint = draw.hint(); + draw.click(157, 50, stations); // 끝 세로선 근처 = 끝 + out.appliedCount = log.applied.length; + out.closed = log.closed; + out.afterPhase = draw.phase(); + const next = edits(); + out.offsetKeys = Object.keys(next.station_offsets).sort(); + out.radiiKeys = Object.keys(next.curve_radii).sort(); + const built = align.buildAlignment(base, next); + const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m; + out.pvi = { 40: pvi(40), 60: pvi(60), 80: pvi(80), 100: pvi(100), 120: pvi(120), 140: pvi(140), 160: pvi(160) }; + out.ground = { 40: z0(40), 160: z0(160) }; + const g1 = (103.46 - z0(40)) / 40; + const g2 = (101.2 - 103.46) / 40; + out.expectR80 = Number((20 / Math.abs(g2 - g1)).toFixed(6)); + out.r80 = next.curve_radii["80.000"]; + out.curve80L = built.curves.find((c) => Math.abs(c.chainage_m - 80) < 1e-6)?.l_m ?? null; + out.outsideUntouched = !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets); +} + +// ④ Enter = 끝(마지막 점 → 끝 세로선) · 끝 세로선에서 거꾸로 시작. +{ + const { draw, log, edits } = harness(); + draw.click(40, 0, stations); + draw.click(160, 0, stations); + draw.click(162, 0, stations); // 끝 쪽 세로선에서 시작 + out.reverseAnchor = draw.marks().dots[0].chainage_m; + draw.click(100, 104, stations); + out.enter = draw.key("Enter"); + out.enterApplied = log.applied.length; + const built = align.buildAlignment(base, edits()); + const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m; + out.enterPvi = { 40: pvi(40), 60: pvi(60), 100: pvi(100), 140: pvi(140), 160: pvi(160) }; +} + +// ⑤ Esc = 취소(편집 없음) · Backspace = 마지막 점 빼기. +{ + const { draw, log } = harness(); + draw.click(40, 0, stations); + draw.click(160, 0, stations); + draw.click(40, 0, stations); + draw.click(80, 102, stations); + draw.click(120, 103, stations); + draw.key("Backspace"); + out.afterBackspace = draw.marks().dots.map((d) => d.chainage_m); + draw.key("Backspace"); + draw.key("Backspace"); + out.backspaceToArmed = draw.phase(); + draw.click(40, 0, stations); + draw.click(80, 102, stations); + out.escape = draw.key("Escape"); + out.escApplied = log.applied.length; + out.escClosed = log.closed; + out.escPhase = draw.phase(); + out.escDots = draw.marks().dots.length; + out.enterOutsideDrawing = draw.key("Enter"); +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_174_b07_cross_cell_stack.py b/resources/tester/test_174_b07_cross_cell_stack.py index cb4982436..eefcac2ce 100644 --- a/resources/tester/test_174_b07_cross_cell_stack.py +++ b/resources/tester/test_174_b07_cross_cell_stack.py @@ -11,7 +11,7 @@ import pytest -from B07_DesignDetail.B07_DesignDetail_Engine_Cad import GROUND_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import GROUND_COLOR from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CELL_TABLE_PAD_MM, build_cross_sheet, @@ -95,7 +95,9 @@ def test_같은_행_표_한_높이_그림_중심_칸_가운데() -> None: assert tables[0][0] == pytest.approx(tables[1][0]) # 도로 중심(offset 0) = 칸 가운데 · 그림(원지반) 아래 끝 = 글(없으면 표) 위 8 mm grounds = [ - entities_bbox([e]) for e in drawing["entities"] if e.get("layerId") == GROUND_LAYER_ID + entities_bbox([e]) + for e in drawing["entities"] + if e.get("type") == "PolyLine" and e.get("lineColor") == GROUND_COLOR ] for placement, slot, table, design, ground in zip( drawing["cross_placements"], sheet["slots"], tables, designs, grounds, strict=True diff --git a/resources/tester/test_180_2_b07_cross_scale.py b/resources/tester/test_180_2_b07_cross_scale.py index 3fc8dec6e..e9d47710d 100644 --- a/resources/tester/test_180_2_b07_cross_scale.py +++ b/resources/tester/test_180_2_b07_cross_scale.py @@ -14,7 +14,7 @@ from pathlib import Path import pytest -from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, GROUND_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, GROUND_COLOR from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CELL_TABLE_PAD_MM, build_cross_sheet, @@ -81,7 +81,9 @@ def test_배율_15_표_크기와_쌓기() -> None: bottom = shape["origin"]["y"] - sum(shape["rowHeights"]) assert bottom == pytest.approx(slot[1] - h / 4 + CELL_TABLE_PAD_MM) # 표 아래선 그대로 grounds = [ - entities_bbox([e]) for e in drawing["entities"] if e.get("layerId") == GROUND_LAYER_ID + entities_bbox([e]) + for e in drawing["entities"] + if e.get("type") == "PolyLine" and e.get("lineColor") == GROUND_COLOR ] # 글 2 줄 × 3.0 × 1.5 = 9 mm · 간격 8 mm 는 배율 안 탐 tops = [t["shapeData"]["origin"]["y"] for t in tables] diff --git a/resources/tester/test_180_b07_cross_group.py b/resources/tester/test_180_b07_cross_group.py index 4feffb1a1..d98296baa 100644 --- a/resources/tester/test_180_b07_cross_group.py +++ b/resources/tester/test_180_b07_cross_group.py @@ -10,6 +10,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( FRAME_LAYER_ID, + GROUND_COLOR, GROUND_LAYER_ID, build_cross_drawing, ) @@ -57,7 +58,9 @@ def _ground_locked(drawing: dict) -> bool: def test_원지반_층_안_잠김() -> None: - assert _ground_locked(_drawing()) is False + # 장 = 원지반이 측점 층에(212-2) · 도각 말고 잠긴 층 없음 + layers = _drawing()["layers"] + assert not any(layer["isLocked"] for layer in layers if layer["id"] != FRAME_LAYER_ID) single = build_cross_drawing(_source(0.0), "cross_00000m", _design_line(), None, None, "") assert _ground_locked(single) is False @@ -72,9 +75,9 @@ def test_측점마다_groupId_하나() -> None: assert {e["groupId"] for e in grouped} == set(group_ids) for group_id in group_ids: members = [e for e in grouped if e["groupId"] == group_id] - layers = {e["layerId"] for e in members} types = {e["type"] for e in members} - assert GROUND_LAYER_ID in layers and "Table" in types and "Text" in types + ground = any(e["type"] == "PolyLine" and e["lineColor"] == GROUND_COLOR for e in members) + assert ground and "Table" in types and "Text" in types # 격자선 · 도각은 묶지 않음 assert all("groupId" not in e for e in drawing["entities"] if e["layerId"] == FRAME_LAYER_ID) assert all(e["layerId"] != FRAME_LAYER_ID for e in grouped) diff --git a/resources/tester/test_212_3_b07_cross_layout_buttons.py b/resources/tester/test_212_3_b07_cross_layout_buttons.py new file mode 100644 index 000000000..3b51c0f18 --- /dev/null +++ b/resources/tester/test_212_3_b07_cross_layout_buttons.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +"""212-3 B07 좌측 「횡단도」 배치 칸 — 단추 · 숫자 칸 한 규격 · 한 줄에 한 일 · 남는 검은 선 판정. + +못박는 것 + - 줄 = 「배치 열 · 행」 · 「[이 장] [모든 장]」 · 「표 배율 [배율 적용]」 + · [안내선 숨기기](부르는 쪽). + - 단추 · 숫자 칸 · 이름 = 줄 위 작은 단추 토큰(187 `--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비. + - 옛 한 줄 감기(flex-wrap) · 숫자 칸 48px 고정 없음. + - [안내선 숨기기] 뒤 남는 검은 선 = 인쇄 칸선(도각 층 도면 개체 · 회청 `#8ea0b5` + · 밝은 바탕에서 짙게 그려짐) — 안내선(겹판)과 다른 것이라 지우지 않음 · 단추는 겹판만 끔. +""" + +import re +from pathlib import Path + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID, TABLE_LINE_COLOR +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import _grid_lines +from B07_DesignDetail.B07_DesignDetail_Engine_Template import use_project_templates + +ROOT = Path(__file__).resolve().parents[2] +CSS = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Style.css").read_text(encoding="utf-8") +PANELS = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Panels.ts").read_text(encoding="utf-8") +PAGE = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Page.ts").read_text(encoding="utf-8") +GUIDE = (ROOT / "B07_DesignDetail/openwebcad/src/integration/guide-area.ts").read_text( + encoding="utf-8" +) +THEME = (ROOT / "ui_template/ui_template_theme.css").read_text(encoding="utf-8") + + +def _block(selector: str) -> str: + m = re.search(r"(?:^|\n)" + re.escape(selector) + r"\s*\{([^}]*)\}", CSS) + assert m, selector + return m.group(1) + + +def _decl(block: str, prop: str) -> str: + m = re.search(r"(?:^|;|\s)" + re.escape(prop) + r"\s*:\s*([^;]+);", block) + assert m, prop + return m.group(1).strip() + + +def test_단추_숫자_칸_한_규격_토큰(): + shared = _block(".b07-cross-layout .ui-btn,\n.b07-cross-layout .ui-field .ui-input") + assert _decl(shared, "padding") == "var(--btn-bar-padding)" + assert _decl(shared, "font-size") == "var(--btn-bar-font-size)" + assert _decl(shared, "line-height") == "1" + assert _decl(shared, "height") == "auto" # 공용 작은 칸 30px 를 풀어 단추 높이에 맞춤 + label = _block(".b07-cross-layout .ui-field__label") + assert _decl(label, "font-size") == "var(--btn-bar-font-size)" + # 토큰은 theme 한 곳(187) + assert "--btn-bar-padding: 1px var(--spacing-8);" in THEME + assert "--btn-bar-font-size: var(--text-caption);" in THEME + + +def test_한_줄에_한_일_같은_너비(): + box = _block(".b07-cross-layout") + assert _decl(box, "flex-direction") == "column" + assert "flex-wrap" not in box + assert _decl(_block(".b07-cross-layout__row > *"), "flex") == "1 1 0" + assert "width: 48px" not in CSS + assert 'line.className = "b07-cross-layout__row"' in PANELS + assert "row(cols.root, rows.root)" in PANELS + assert re.search(r"row\(\s*action\(\"이 장\"[^;]*action\(\"모든 장\"", PANELS, re.S) + assert "box.append(row(scale.root, button));" in PANELS + # 안내선 단추 = 배치 칸 맨 아래 한 줄(전체 폭) + assert "controls.append(guideToggle);" in PAGE + + +def test_남는_검은_선은_인쇄_칸선_지우지_않음(): + use_project_templates(None) + lines = _grid_lines("cross_s00000m", 4, 4) + assert len(lines) == 10 # 세로 5 · 가로 5 그대로 + assert all(e["layerId"] == FRAME_LAYER_ID for e in lines) + assert all(e["lineColor"] == TABLE_LINE_COLOR == "#8ea0b5" for e in lines) + # 안내선 단추는 겹판(점선 영역 + 붉은 틀)만 — 도면 개체를 안 건드림 + draw = GUIDE[GUIDE.index("export function drawGuide") :] + draw = draw[: draw.index("\n}\n")] + assert "if (!guide || !guideVisible) return;" in draw + assert "getEntities" not in draw and "setEntities" not in draw diff --git a/resources/tester/test_212_b07_cross_station_layers.py b/resources/tester/test_212_b07_cross_station_layers.py new file mode 100644 index 000000000..baa293a31 --- /dev/null +++ b/resources/tester/test_212_b07_cross_station_layers.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""212-1 · 212-2 B07 횡단 장 — 원지반 고르기 · 측점별 도면층. + +못박는 것 + - 212-1 옛 확정 장(원지반 층 잠금째 저장)도 열 때 원지반 잠금 풂 · 측점 층 없으면 stale_layers. + - 212-2 장의 측점마다 층 하나(`b08-station-{측점}` · 이름 「횡단 {제목}」) = 원지반 · 계획선 · + 암 그림(자식 선까지) · 표 · 글은 수량표 층 · 격자선 · 도각은 도각 층 · 종류 층 없음. + - 개체 id 는 그대로(층만 바뀜) · placement 에 layer_id · design_ids(화면 덧그림 갈아 끼우기). + - DXF 층 이름 = 측점 층 id 그대로(금지 문자 없음). + - 화면 덧그림도 측점 층 · design_ids 로 서버 설계선 걷음(소스 확인 · typecheck 는 따로). +""" + +import json +from pathlib import Path +from uuid import uuid5 + +import ezdxf + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + _POLY_NS, + CROSS_TABLE_LAYER_ID, + DESIGN_COLOR, + DESIGN_LAYER_ID, + DRAWING_FORMAT, + FRAME_LAYER_ID, + GROUND_COLOR, + GROUND_LAYER_ID, + ROCK_LAYER_ID, + STRUCTURE_LAYER_ID, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + STATION_LAYER_PREFIX, + build_cross_sheet, + plan_cross_sheets, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import _layer_name, drawing_to_dxf +from B07_DesignDetail.B07_DesignDetail_Engine_Template import use_project_templates +from B07_DesignDetail.B07_DesignDetail_Router_Support import CROSS_SHEET_LAYOUT, _read_drawing + +ROOT = Path(__file__).resolve().parents[2] +KIND_LAYERS = {GROUND_LAYER_ID, DESIGN_LAYER_ID, STRUCTURE_LAYER_ID, ROCK_LAYER_ID} + + +def _source(chainage: float) -> dict: + return { + "chainage_m": chainage, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.1 * x} + for x in range(-5, 6) + ], + } + + +def _sheet() -> tuple[dict, dict]: + use_project_templates(None) + chainages = [0.0, 20.0, 40.0, 60.0] + sheet = plan_cross_sheets(chainages, {"default": [2, 2]})[0] + sections = [ + { + "chainage": c, + "source": _source(c), + # 암 지반 = 암 경계선도 측점 층으로 + "design": {"rock_boundary_offset_m": -1.0} if c == 20.0 else None, + "design_line": [{"offset_m": x, "elevation_m": 100.0} for x in (-3.0, 0.0, 3.0)], + "quantity_table": {"ground": 1.0, "planned": 0.5}, + "title": f"No.{int(c // 20)}", + } + for c in chainages + ] + return sheet, build_cross_sheet(sheet, sections) + + +def _flat(entities: list[dict]) -> list[dict]: + out = [] + for entity in entities: + out.append(entity) + out.extend(_flat(entity.get("children") or [])) + return out + + +def test_측점마다_층_하나_이름_횡단_No(): + _sheet_, drawing = _sheet() + layers = drawing["layers"] + ids = [layer["id"] for layer in layers] + assert ids[:4] == [f"{STATION_LAYER_PREFIX}{c}" for c in (0.0, 20.0, 40.0, 60.0)] + assert [layer["name"] for layer in layers[:4]] == [ + "횡단 No.0", + "횡단 No.1", + "횡단 No.2", + "횡단 No.3", + ] + assert ids[4:] == [CROSS_TABLE_LAYER_ID, FRAME_LAYER_ID] + assert not KIND_LAYERS & set(ids) # 종류 층 없음 — 빈 층이 안 남음 + assert all(layer["isVisible"] and not layer["isLocked"] for layer in layers[:4]) + + +def test_그림은_측점_층_표_글은_수량표_층_격자는_도각(): + _sheet_, drawing = _sheet() + flat = _flat(drawing["entities"]) + assert not any(e["layerId"] in KIND_LAYERS for e in flat) # 자식 선까지 + by_layer: dict[str, list[dict]] = {} + for entity in drawing["entities"]: + by_layer.setdefault(entity["layerId"], []).append(entity) + for chainage in (0.0, 20.0, 40.0, 60.0): + members = by_layer[f"{STATION_LAYER_PREFIX}{chainage}"] + colors = {e["lineColor"] for e in members} + assert GROUND_COLOR in colors and DESIGN_COLOR in colors + assert all(e["type"] == "PolyLine" for e in members) + assert len({e["groupId"] for e in members}) == 1 # 180-1 groupId 그대로 + # 암 지반 측점만 점선 암 경계선(종류 = 선 꼴) + rock = [e for e in by_layer[f"{STATION_LAYER_PREFIX}20.0"] if e.get("lineDash")] + assert len(rock) == 1 + assert not any(e.get("lineDash") for e in by_layer[f"{STATION_LAYER_PREFIX}0.0"]) + assert {e["type"] for e in by_layer[CROSS_TABLE_LAYER_ID]} <= {"Table", "Text"} + assert all("groupId" not in e for e in by_layer[FRAME_LAYER_ID]) + + +def test_id_그대로_placement_layer_id_design_ids(): + sheet, drawing = _sheet() + by_id = {e["id"]: e for e in drawing["entities"]} + for placement in drawing["cross_placements"]: + chainage = placement["chainage_m"] + layer_id = f"{STATION_LAYER_PREFIX}{chainage}" + assert placement["layer_id"] == layer_id + seed = f"{sheet['id']}:{chainage}" + # 개체 id 는 종류 층 씨앗 그대로 — 저장본 · 덧그림 짝이 안 흔들림 + ground = by_id[str(uuid5(_POLY_NS, f"{seed}:{GROUND_LAYER_ID}"))] + assert ground["layerId"] == layer_id and ground["lineColor"] == GROUND_COLOR + design_ids = placement["design_ids"] + assert design_ids == [str(uuid5(_POLY_NS, f"{seed}:{DESIGN_LAYER_ID}"))] + assert all(by_id[i]["lineColor"] == DESIGN_COLOR for i in design_ids) + + +def test_DXF_층_이름_측점_층_id(tmp_path: Path): + _sheet_, drawing = _sheet() + for layer in drawing["layers"]: + assert _layer_name(layer["id"]) == layer["id"] + data, _skipped = drawing_to_dxf(drawing) + (tmp_path / "sheet.dxf").write_bytes(data) + names = {layer.dxf.name for layer in ezdxf.readfile(tmp_path / "sheet.dxf").layers} + assert {f"{STATION_LAYER_PREFIX}{c}" for c in (0.0, 20.0, 40.0, 60.0)} <= names + assert not KIND_LAYERS & names + + +def test_옛_확정_장_원지반_잠금_풂_측점_층_안내(tmp_path: Path): + stage = tmp_path / "B07_DesignDetail" + (stage / "drawings").mkdir(parents=True) + layers = [ + {"id": GROUND_LAYER_ID, "name": "원지반", "isVisible": True, "isLocked": True}, + {"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": True}, + ] + saved = {"format": DRAWING_FORMAT, "entities": [], "layers": layers} + path = stage / "drawings" / "cross_s00000m.json" + path.write_text(json.dumps(saved), encoding="utf-8") + entry = {"kind": "cross", "label": "1장", "confirmed": True, "layout": CROSS_SHEET_LAYOUT} + (stage / "manifest.json").write_text( + json.dumps({"drawings": {"cross_s00000m": entry}}), encoding="utf-8" + ) + + def read() -> dict: + _k, _l, drawing, confirmed, _t = _read_drawing( + tmp_path, tmp_path / "x.json", "cross_s00000m" + ) + assert confirmed + return drawing + + drawing = read() + locked = {layer["id"]: layer["isLocked"] for layer in drawing["layers"]} + assert locked == {GROUND_LAYER_ID: False, FRAME_LAYER_ID: True} # 도각은 그대로 잠금 + assert drawing.get("stale_layers") is True + # 측점 층으로 확정한 장은 안내 없음 + saved["layers"] = [{"id": f"{STATION_LAYER_PREFIX}0", "name": "횡단 No.0"}, layers[1]] + path.write_text(json.dumps(saved), encoding="utf-8") + assert "stale_layers" not in read() + + +def test_화면_덧그림_측점_층_design_ids(): + struct = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts").read_text( + encoding="utf-8" + ) + assert "layer_id?: string;" in struct and "design_ids?: string[];" in struct + assert "moveToLayer(entity, placement.layer_id)" in struct + assert "!designIds.has(entity.id as string)" in struct + page = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Page.ts").read_text(encoding="utf-8") + assert "response.drawing.stale_layers" in page diff --git a/resources/tester/test_215_1_free_profile.py b/resources/tester/test_215_1_free_profile.py new file mode 100644 index 000000000..e245e7a61 --- /dev/null +++ b/resources/tester/test_215_1_free_profile.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +"""215-1 B05 종단 [자유지정] — 범위 고르고 클릭으로 계획선 잇기. + +계산 · 상태(`A00_Common/design/design_profile_free.ts`)는 Node 헬퍼 +(`helper_215_1_free_profile.cjs`)가 트랜스파일해 돌리고, 화면 배선(도구줄 단추 · 오버레이 · +키 · 적용 길)은 소스로 본다. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_215_1_free_profile.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_높이_변환_세로_창_왕복(out): + assert out["yAt103_2"] == pytest.approx(121.6) + assert out["zBack"] == pytest.approx(103.2) + assert out["parsed"] == {"scale": 1.2, "offset": -20} + assert out["parsedNone"] == {"scale": 1, "offset": 0} + + +def test_세로선_붙음_범위_안만(out): + assert out["snap79"] == 80 + assert out["snapOutside"] == 160 + + +def test_범위_고르기_강조(out): + assert out["pendingEdges"] == [40] + assert out["rangePhase"] == "armed" + assert out["band"] == [40, 160] + assert out["stillArmed"] == "armed" # 가운데 클릭은 시작이 아님 + + +def test_시작_이음_지금_계획고(out): + assert out["startPhase"] == "drawing" + assert out["startDot"] == {"chainage_m": 40, "elevation_m": pytest.approx(100.6)} + + +def test_클릭_점_세로선_붙음_높이(out): + assert out["point1"] == {"chainage_m": 80, "elevation_m": pytest.approx(103.46)} + assert out["dotsAfterBack"] == 2 # 뒤로 가는 클릭은 무시 + + +def test_점선_미리_보기_기울기_글(out): + first, last = out["preview"] + assert (first["preview"], last["preview"]) == (False, True) + assert last["to"] == {"chainage_m": 120, "elevation_m": pytest.approx(101.2)} + assert first["grade"] == pytest.approx(7.15) + assert last["grade"] == pytest.approx(-5.65) + assert not first["over"] and not last["over"] + + +def test_기준_넘음_빨강(out): + assert out["steep"]["over"] is True + assert abs(out["steep"]["grade"]) > 14 + + +def test_끝_이음과_적용_편집(out): + assert out["finishPreview"] == {"chainage_m": 160, "elevation_m": pytest.approx(102.4)} + assert out["appliedCount"] == 1 and out["closed"] == 1 and out["afterPhase"] == "range" + assert out["offsetKeys"] == ["100.000", "120.000", "140.000", "60.000", "80.000"] + pvi = out["pvi"] + assert pvi["40"] == pytest.approx(out["ground"]["40"]) # 양 끝 그대로 + assert pvi["160"] == pytest.approx(out["ground"]["160"]) + assert pvi["80"] == pytest.approx(103.46) + assert pvi["120"] == pytest.approx(101.2) + assert pvi["60"] == pytest.approx((100.6 + 103.46) / 2) # 점 사이 = 직선 + assert pvi["140"] == pytest.approx((101.2 + 102.4) / 2) + assert out["outsideUntouched"] is True + + +def test_종단곡선_자동(out): + assert out["radiiKeys"] == ["120.000", "80.000"] + assert out["r80"] == pytest.approx(out["expectR80"]) + assert out["curve80L"] == pytest.approx(20) # 정책 기본 길이 L + + +def test_Enter_끝_거꾸로_시작(out): + assert out["reverseAnchor"] == 160 + assert out["enter"] is True and out["enterApplied"] == 1 + pvi = out["enterPvi"] + assert pvi["40"] == pytest.approx(100.6) and pvi["160"] == pytest.approx(102.4) + assert pvi["100"] == pytest.approx(104) + assert pvi["60"] == pytest.approx(100.6 + (104 - 100.6) / 3, abs=1e-5) + + +def test_Backspace_와_Esc_취소(out): + assert out["afterBackspace"] == [40, 80] + assert out["backspaceToArmed"] == "armed" + assert out["escape"] is True + assert out["escApplied"] == 0 and out["escClosed"] == 1 + assert out["escPhase"] == "range" and out["escDots"] == 0 + assert out["enterOutsideDrawing"] is False + + +def test_도구줄_직선화_오른쪽_자유지정(): + tools = _read("B05_Profile/B05_Profile_UI_Profile_Tools.ts") + assert '"자유지정"' in tools + assert "wrap.append(reset, undo, redo, straighten, free, shift, up, down)" in tools + assert 'className = "b05-route-profile__tool"' in tools # 같은 단추 양식 + + +def test_적용은_직선화와_같은_길_키_배선(): + panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") + assert "apply: (next) => ctx.applyEdits(next)" in panel + assert 'tools.mode() === "free" && free.key(key)' in panel + profile = _read("A00_Common/design/design_profile.ts") + # applyEdits = 캐시(store.replace) + 되돌리기 기록 — 자동저장 없음 + assert "store.replace(next);\n history.record();" in profile + render = _read("B05_Profile/B05_Profile_UI_Profile_Render.ts") + assert "ctx.tools.mountOverlay(" in render + + +def test_오버레이_점선_빨강_글(): + view = _read("A00_Common/design/design_profile_free_view.ts") + assert "is-preview" in view and "is-over" in view + assert "grade_pct.toFixed(1)}%" in view + css = _read("B05_Profile/B05_Profile_UI_Style_Table.css") + assert ".b05-profile-freemark__line {" in css and "stroke-dasharray: 6 4" in css + assert ".b05-profile-freemark__line.is-over" in css + + +def test_줄_수_늘리지_않음(): + for relative in ( + "A00_Common/design/design_profile.ts", + "A00_Common/design/design_profile_free.ts", + "A00_Common/design/design_profile_free_view.ts", + ): + assert len(_read(relative).splitlines()) <= 700 + assert len(_read("A00_Common/design/design_profile.ts").splitlines()) <= 698 diff --git a/resources/tester/test_217_1_b06_notice_spacing.py b/resources/tester/test_217_1_b06_notice_spacing.py new file mode 100644 index 000000000..e15d8ef74 --- /dev/null +++ b/resources/tester/test_217_1_b06_notice_spacing.py @@ -0,0 +1,30 @@ +"""217-1 B06 알림 칩 줄 여백 줄임 — 줄 위 여백 16→8 · 칩 줄 ↔ 첫 카드 28→16 (CSS 값 고정).""" + +from test_196_1_b06_notice_chips import B06 + +CSS = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8") + + +def _rule(selector: str) -> str: + body = CSS[CSS.index(selector + " {") :] + return body[: body.index("}")] + + +def test_칩_줄_위_여백은_8_아래_여백은_0(): + bar = _rule(".b06-section__notice-bar") + assert "padding-block: var(--spacing-8) 0;" in bar + assert "spacing-16) var(--spacing-4)" not in bar + + +def test_첫_카드와의_간격은_카드_칸_gap_24에서_8을_당겨_16(): + bar = _rule(".b06-section__notice-bar") + # 위 = 카드 칸 padding(16) 상쇄 · 아래 = gap(24) 중 8 당김 + assert "margin-block: calc(-1 * var(--spacing-16)) calc(-1 * var(--spacing-8));" in bar + assert "gap: var(--spacing-24)" in _rule(".b06-section__cards") + + +def test_sticky_top_과_scroll_margin_그대로(): + bar = _rule(".b06-section__notice-bar") + assert "position: sticky" in bar + assert "top: calc(-1 * var(--spacing-16))" in bar + assert "scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8))" in CSS