From 10eb54f5e58f92996604e936b80bf39ed64b68b0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:21:50 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(B06):=20[=EC=83=81=EC=84=B8]=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EA=B5=90=EC=B0=A8=EC=A0=90=20=EB=B6=84=ED=95=A0=20?= =?UTF-8?q?=C2=B7=20=EC=97=AC=EB=9F=AC=20=EA=B3=A0=EB=A6=84(=EC=9B=90?= =?UTF-8?q?=EC=A7=80=EB=B0=98=20=ED=8F=AC=ED=95=A8)=20=C2=B7=20=EB=8B=AB?= =?UTF-8?q?=ED=9E=8C=20=EB=84=93=EC=9D=B4=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20?= =?UTF-8?q?=EA=B2=83=20=EA=B0=92=20=C2=B7=20=EC=84=9C=EB=B2=84=20=EC=A1=B0?= =?UTF-8?q?=EA=B0=81=20=EA=B2=80=EC=82=B0=20(209-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선(설계 · 구조물 · 원지반 · 사토장)을 만나는 점에서 조각으로 나눔 · 조각마다 판정 선 - 누름 = 하나 · Shift/Ctrl+누름 = 더하기/빼기 · [+ − ⤢] 왼쪽 값 두 줄(길이 합 · 면적 · 닫히지 않음) - 칸 넣기 = 우클릭 고르기 길 그대로 · 길이 한 번 누름 = 바로 · 여럿 = Enter · [칸에 넣기] - 저장 = 조각 두 끝 목록(pieces) · 서버가 설계선 · 원지반선 · 사토장선 위 검산 · 구조물 선은 두 끝 직선 · 옛 두 끝 꼴 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B06_Section/B06_Section_Api_Types.ts | 8 + B06_Section/B06_Section_Router_CrossTable.py | 2 +- B06_Section/B06_Section_Schema.py | 12 +- B06_Section/B06_Section_UI_Cross_Detail.ts | 5 +- .../B06_Section_UI_Cross_Detail_Geom.ts | 227 ++++++++++++++++ .../B06_Section_UI_Cross_Detail_Select.ts | 245 ++++++++++++++++++ .../B06_Section_UI_Cross_Detail_Table.ts | 124 +++++---- .../B06_Section_UI_Style_Cross_Detail.css | 35 ++- ...07_DesignDetail_Engine_Cross_Table_Edit.py | 147 ++++++++++- .../B07_DesignDetail_Router_Support.py | 2 +- resources/tester/helper_164_detail_zoom.cjs | 2 + .../tester/helper_179_4_detail_table.cjs | 15 +- .../tester/helper_209_1_detail_select.cjs | 117 +++++++++ resources/tester/test_179_4_detail_table.py | 9 +- resources/tester/test_209_1_detail_select.py | 199 ++++++++++++++ 15 files changed, 1070 insertions(+), 79 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Detail_Geom.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Detail_Select.ts create mode 100644 resources/tester/helper_209_1_detail_select.cjs create mode 100644 resources/tester/test_209_1_detail_select.py 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_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_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_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_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_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..8932f7cd2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -313,7 +313,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 diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs index 9cb7ffa3f..a56ff0baa 100644 --- a/resources/tester/helper_164_detail_zoom.cjs +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -35,6 +35,8 @@ const STUBS = { svgText, }, "./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} }, + // 선 조각 고르기(209-1)는 줌과 무관 — 따로 시험(`test_209_1_detail_select.py`) + "./B06_Section_UI_Cross_Detail_Select": { attachDetailSelect: () => ({}), PIECE_HIT_CLASS: "" }, }; const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); function loadTs(full) { diff --git a/resources/tester/helper_179_4_detail_table.cjs b/resources/tester/helper_179_4_detail_table.cjs index 80bf13535..743921ada 100644 --- a/resources/tester/helper_179_4_detail_table.cjs +++ b/resources/tester/helper_179_4_detail_table.cjs @@ -25,6 +25,8 @@ const store = () => { }; }; globalThis.window = { sessionStorage: store(), localStorage: store() }; +// 조정창 공용 부품(Select → Design_Pick → Cross_Panel_Base)이 불러올 때 문서에 귀를 닮 +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; const STUBS = { "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, @@ -38,6 +40,9 @@ function loadTs(full) { const box = { exports: {} }; const localRequire = (request) => { if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); return loadTs(resolve(path.join(path.dirname(full), request))); }; new Function("exports", "module", "require", js)(box.exports, box, localRequire); @@ -85,12 +90,10 @@ const px = ([offset, elevation]) => [ (AXES.displayMax - (AXES.elevationMid + (elevation - AXES.elevationMid) * AXES.exaggeration)) * AXES.pixelsPerMeter, ]; -const points = input.pick - .map(px) - .map((p) => p.join(",")) - .join(" "); -out.length = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points }), AXES); -out.lengthBad = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points: "1,2" }), AXES); +// 화면 px → m(209-1 `_Cross_Detail_Select.pxToM`) · 길이 = 옛 저장 꼴(두 끝) 그대로 읽음 +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); +out.pxBack = input.pick.map(px).map(S.pxToM(AXES)); +out.length = { src: "length", start: input.pick[0], end: input.pick[1] }; out.lengthValue = T.localCellValue(out.length, input.design); // ③ 우클릭 메뉴 항목 diff --git a/resources/tester/helper_209_1_detail_select.cjs b/resources/tester/helper_209_1_detail_select.cjs new file mode 100644 index 000000000..81862f3cd --- /dev/null +++ b/resources/tester/helper_209_1_detail_select.cjs @@ -0,0 +1,117 @@ +/* PLAN 209-1 단위검증 헬퍼 — [상세] 선 조각 고르기(`_Cross_Detail_Geom` · `_Cross_Detail_Select`). + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes} · + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_209_1_detail_select.py`(파이썬 `cell_value` 거울). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); +const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts")); + +const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; + +// ① 그림 요소(화면 px) → 조각(m) — 설계선(직선 토막 셋) · 원지반선 · 구조물 선 아님 +const px = ([offset, elevation]) => + [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ].join(","); +const el = (classes, points, attrs = {}) => ({ + tagName: "polyline", + classList: { contains: (name) => classes.includes(name) }, + hasAttribute: (name) => name in attrs, + getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)), +}); +const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]); +const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]); +const pieces = S.collectPieces( + [ + el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }), + el(["b06-chart__cross-profile"], groundPts), + el( + ["b06-chart__grid"], + [ + [0, 0], + [1, 1], + ], + ), + ], + axes, +); +out.pieces = pieces; + +// ② 여러 고름 — 더하기 · 빼기 · 하나 · 다시 누르면 비움 +out.toggle = { + single: S.toggleSelection([1, 2], 3, false), + again: S.toggleSelection([3], 3, false), + add: S.toggleSelection([1], 3, true), + remove: S.toggleSelection([1, 3], 3, true), +}; + +// ③ 닫힌 영역 — 땅 아래 설계 골(경사 둘 · 바닥) + 원지반 가운데 조각 +const isGround = (p) => p.line === "ground"; +const mid = (p) => (p.points[0][0] + p.points[p.points.length - 1][0]) / 2; +const below = pieces.filter( + (p) => !isGround(p) && Math.max(...p.points.map((q) => q[1])) <= 100.0001, +); +const groundMid = pieces.filter((p) => isGround(p) && Math.abs(mid(p)) < 1); +const ring = [...below, ...groundMid]; +out.ringCount = ring.length; +out.label = { + ring: S.selectionLabel(design, ring), + open: S.selectionLabel(design, below), + single: S.selectionLabel(design, [below.find((p) => Math.abs(mid(p)) < 0.5)]), + ground: S.selectionLabel(design, groundMid), +}; +out.area = G.ringArea(ring.map((p) => p.points)); +out.open = G.ringArea(below.map((p) => p.points)); + +// ④ 칸 넣기 출처(저장 꼴) · 즉시 칸 값(짝 ①) +out.sourceArea = S.selectionSource("area", ring); +out.sourceOpen = S.selectionSource("area", below); +out.sourceLength = S.selectionSource("length", [...below, ...groundMid]); +const withStructure = { + src: "length", + pieces: [...out.sourceLength.pieces, { line: "structure", start: [20, 0], end: [23, 4] }], +}; +const offLine = { + src: "length", + pieces: [{ line: "ground", start: [0, 101], end: [1, 100] }], +}; +out.sources = [out.sourceArea, out.sourceLength, withStructure, offLine]; +out.local = out.sources.map((source) => T.localCellValue(source, design, samples)); +out.lengthSum = [...below, ...groundMid].reduce((sum, p) => sum + G.pathLength(p.points), 0); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_179_4_detail_table.py b/resources/tester/test_179_4_detail_table.py index a513fc37e..6df144346 100644 --- a/resources/tester/test_179_4_detail_table.py +++ b/resources/tester/test_179_4_detail_table.py @@ -113,10 +113,8 @@ def test_그래프_고르기_띠_키_판정_선_m(out): assert area["fill"] == {"src": "area", "key": "embankment"} assert area["spoil"] == {"src": "area", "key": "spoil_fill"} assert area["other"] is None - length = out["length"] - assert length["src"] == "length" - assert length["start"] == pytest.approx(PICK[0]) and length["end"] == pytest.approx(PICK[1]) - assert out["lengthBad"] is None + # 화면 px → m(209-1 `pxToM`) · 옛 저장 꼴(두 끝) 길이 = hypot 그대로 + assert out["pxBack"][0] == pytest.approx(PICK[0]) and out["pxBack"][1] == pytest.approx(PICK[1]) assert out["lengthValue"] == pytest.approx(hypot(4.0, 2.0)) @@ -261,4 +259,5 @@ def test_저장_patch_배선_초안_등록(): assert '"detail-table": { bucket: "draft", scope: "route" }' in state detail = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Detail.ts").read_text(encoding="utf-8") assert "designForTable(state.section, projectId, routeId)" in detail - assert "const detachTable = attachDetailTable({" in detail + assert "const tablePick = attachDetailTable({" in detail + assert "tablePick.redraw();" in detail diff --git a/resources/tester/test_209_1_detail_select.py b/resources/tester/test_209_1_detail_select.py new file mode 100644 index 000000000..b695354c9 --- /dev/null +++ b/resources/tester/test_209_1_detail_select.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +"""PLAN 209-1 — B06 [상세] 그래프 고르기(교차점 분할 · 여러 고름 · 원지반 포함 · 닫힌 넓이). + +정보창(고른 것 길이 · 면적) · 칸 넣기 · 서버 검산 · 옛 꼴 읽기. + +못박는 것 + - 선(설계선 · 원지반선)이 만나는 점에서 조각으로 나뉨 · 화면 px → m · 격자 등은 안 고름. + - 여러 고름(Shift/Ctrl 더하기 · 빼기) · 원지반 조각 포함 닫힌 영역 넓이 · 안 닫히면 null. + - 정보창 두 줄(하나 = 카드 꼴 길이 · 경사비율 / 여럿 = 길이 합 · 면적 · 「닫히지 않음」). + - 칸 넣기 출처(조각 두 끝 목록) → 스키마 → 서버 `cell_value` 거울(① 파이썬 · TS) · + 서버 검산(선 밖 · 설계선 바뀜 → 다시 고름 · 구조물 선 = 두 끝 직선) · 옛 두 끝 꼴 그대로. + +./venv/Scripts/python.exe -m pytest resources/tester/test_209_1_detail_select.py -q +""" + +import json +import os +import subprocess +from math import hypot +from pathlib import Path + +import pytest + +from B06_Section import B06_Section_Router_CrossTable as table_mod +from B06_Section.B06_Section_Schema import CrossSectionPatch +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import ( + cell_value, + ground_runs, + ring_area, + sub_path, +) + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + +LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)] +DESIGN = { + "design_elevation_m": 98.0, + "road_edges": { + "left": {"offset_m": 2.0, "elevation_m": 98.0}, + "right": {"offset_m": -2.0, "elevation_m": 98.0}, + }, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE], +} +SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] + [ + {"offset_m": 12.0, "elevation_m": None, "valid": False}, + {"offset_m": 14.0, "elevation_m": 101.0, "valid": True}, +] +AXES = { + "widthPx": 600, + "heightPx": 300, + "pixelsPerMeter": 10, + "maxOffset": 14, + "displayMax": 110, + "elevationMid": 100, + "exaggeration": 2, +} +CROSS_X = 2 + 4 / 3 # 경사(1:0.667)가 원지반 100 m 와 만나는 offset +AREA = (2 * CROSS_X + 4) / 2 * 2 # 사다리꼴(윗변 2·CROSS_X · 아랫변 4 · 높이 2) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_209_1_detail_select.cjs")], + input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _ends(piece: dict) -> list[tuple[float, float]]: + pts = piece["points"] + return [tuple(round(v, 4) for v in pts[0]), tuple(round(v, 4) for v in pts[-1])] + + +# ── 분할 · 여러 고름 ───────────────────────────────────────────────────── + + +def test_교차점에서_조각으로_나뉨(out): + pieces = out["pieces"] + ground = [p for p in pieces if p["line"] == "ground"] + design = [p for p in pieces if p["line"] == "design"] + assert len(ground) == 3 and len(design) == 5 # 원지반 = 두 교점에서 셋 · 설계 경사 둘이 둘씩 + c = round(CROSS_X, 4) + assert sorted(_ends(p)[0][0] for p in ground) == pytest.approx([-10.0, -c, c]) + # 원지반 가운데 조각은 사이 샘플 꼭짓점(0 m)을 지남 — 꺾은선 그대로 + middle = next(p for p in ground if abs(p["points"][0][0]) < 4) + assert [0.0, 100.0] in [[round(v, 6) for v in q] for q in middle["points"]] + # 경사 조각 끝 = 교점(설계선 위 · 원지반 위) + assert any(_ends(p)[1] == (c, 100.0) or _ends(p)[0] == (c, 100.0) for p in design) + + +def test_여러_고름_더하기_빼기(out): + toggle = out["toggle"] + assert toggle == {"single": [3], "again": [], "add": [1, 3], "remove": [1]} + + +# ── 닫힌 넓이 · 정보창 ────────────────────────────────────────────────── + + +def test_원지반_포함_닫힌_영역_넓이(out): + assert out["ringCount"] == 4 # 경사 둘 · 바닥 · 원지반 가운데 + assert out["area"] == pytest.approx(AREA) + assert out["open"] is None # 원지반 빼면 안 닫힘 + + +def test_정보창_값(out): + label = out["label"] + length = 2 * hypot(4 / 3, 2) + 4 + 2 * CROSS_X + assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡" + assert label["open"].endswith("면적 닫히지 않음") + assert label["single"] == "길이 4.00m · 경사비율 0.0%\n수평 4.00m · 높이 0.00m" # 카드 꼴 + assert label["ground"] == ( + f"원지반선 길이 {2 * CROSS_X:.2f}m\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m" + ) + + +# ── 칸 넣기 · 짝 · 서버 검산 ───────────────────────────────────────────── + + +def test_칸_넣기_출처_저장_꼴(out): + area = out["sourceArea"] + assert area["src"] == "area" and len(area["pieces"]) == 4 + assert {p["line"] for p in area["pieces"]} == {"design", "ground"} + assert out["sourceOpen"] is None # 안 닫힌 면적은 칸에 못 넣음 + assert out["sourceLength"]["src"] == "length" + patch = CrossSectionPatch( + chainage_m=40.0, + detail_table={"cells": {"cut_soil": area, "fence": out["sourceLength"]}}, + ).model_dump() + assert patch["detail_table"]["cells"]["cut_soil"]["pieces"][0]["line"] in ("design", "ground") + + +def test_칸_값_파이썬_TS_거울(out): + for source, local in zip(out["sources"], out["local"], strict=True): + number, _text, again = cell_value(source, DESIGN, SAMPLES) + if number is None: + assert local is None and again is True, source + else: + assert local == pytest.approx(number, abs=1e-6), source + assert out["local"][0] == pytest.approx(AREA, abs=1e-3) + assert out["local"][1] == pytest.approx(out["lengthSum"], abs=1e-3) + assert out["local"][2] == pytest.approx(out["lengthSum"] + 5.0, abs=1e-3) # 구조물 = 두 끝 직선 + assert out["local"][3] is None # 원지반 밖 끝 + + +def test_서버_검산_선_밖이나_설계선_바뀌면_다시_고름(out): + cells = {"cut_soil": out["sourceArea"], "fence": out["sourceLength"]} + stored = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": cells}).model_dump() + design = {**DESIGN, "detail_table": stored["detail_table"]} + source = {"chainage_m": 40.0, "center_z": 100.0, "samples": SAMPLES} + sheet = table_mod.cross_quantity_sheet(source, design) + assert sheet["quantity_table"]["cut_soil"] == pytest.approx(AREA, abs=1e-3) + assert sheet["quantity_table"]["fence"] == pytest.approx(out["lengthSum"], abs=1e-3) + assert sheet["repick"] == [] + # 원지반 샘플이 없으면(검산 못 함) 다시 고름 — 브라우저 값 안 받음 + blind = table_mod.cross_quantity_sheet({"chainage_m": 40.0}, design) + assert sorted(blind["repick"]) == ["cut_soil", "fence"] + # 설계선이 내려가면 조각 끝이 선 밖 → 다시 고름 + moved = {**design, "design_line": [{"offset_m": x, "elevation_m": y - 1} for x, y in LINE]} + again = table_mod.cross_quantity_sheet(source, moved) + assert again["quantity_table"]["cut_soil"] is None + assert sorted(again["repick"]) == ["cut_soil", "fence"] + + +def test_옛_두_끝_꼴_그대로_읽음(): + old = {"src": "length", "start": [2.0, 98.0], "end": [6.0, 104.0]} + assert cell_value(old, DESIGN, SAMPLES)[0] == pytest.approx(hypot(4.0, 6.0)) + assert cell_value({"src": "area", "key": "cut_soil"}, {"cut_soil_area_m2": 1.5}) == ( + 1.5, + None, + False, + ) + + +def test_서버_조각_길_넓이_기본(): + assert ground_runs(SAMPLES) == [ + [(-10, 100.0), (-5, 100.0), (0, 100.0), (5, 100.0), (10, 100.0)] + ] + run = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0)] + assert sub_path(run, 3.0, 1.0) == [(2.0, 1.0), (2.0, 0.0), (1.0, 0.0)] + square = [ + [(0.0, 0.0), (1.0, 0.0)], + [(1.0, 1.0), (1.0, 0.0)], + [(1.0, 1.0), (0.0, 1.0), (0.0, 0.0)], + ] + assert ring_area(square) == pytest.approx(1.0) + assert ring_area(square[:2]) is None + assert ring_area([*square, [(5.0, 5.0), (6.0, 6.0)]]) is None # 남는 조각 From 002165f040d2d3c8cbfcf2b8c0b1e9e46210ee2c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:22:52 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(B05=C2=B7B06):=20=ED=95=9C=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EC=B8=A1=EC=A0=90=20=EB=91=98=20=EC=88=A8=EC=9D=8C?= =?UTF-8?q?=20=E2=80=94=20=EC=B8=A1=EC=A0=90=200.006=20m=20=EC=95=88=20?= =?UTF-8?q?=EA=BC=AD=EC=A7=93=EC=A0=90=20=C2=B7=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=ED=82=A4=EB=A5=BC=20=EA=B7=B8=20=EC=B8=A1=EC=A0=90=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=A8=EC=9D=8C(213-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭: 자동설계 관 측점 263.206 → 화면 저장 0.01 m 반올림 263.21 로 측점이 다시 섬 · 계획선 꼭짓점 · 편집 키는 옛 글자 그대로 → 새 측점 편집과 0.004 m 사이 두 꼭짓점(−15003% 계단 · 엄상준_검증 No.13+3) - 계획선 공용 함수 짝(파이썬 snap_to_stations ↔ TS snapToStations) — 측점 위 것이 이김 · 옮긴 꼭짓점은 옛 선의 측점 높이(측점 계획고 그대로) · 양 끝 · 측점에서 먼 키는 그대로 · 읽을 때 · [저장] 때 모두 정리 - 192 병합(_follow_moved_stations)도 옛 측점 0.006 m 안 글자를 같은 측점으로 봄 - 시험 test_213_1(합성 · 거울 · 엄상준 사본 재현) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../B05_Profile_Engine_Grade_Alignment.py | 72 ++++++ B05_Profile/B05_Profile_Router_Confirm.py | 34 ++- .../B05_Profile_UI_Profile_Alignment.ts | 65 +++++- .../tester/test_213_1_hidden_station_pvi.py | 214 ++++++++++++++++++ 4 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_213_1_hidden_station_pvi.py diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 254b252d8..82e8e274f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -158,6 +158,62 @@ 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 resolve_pvi( base_s: np.ndarray, base_z: np.ndarray, @@ -407,6 +463,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_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_UI_Profile_Alignment.ts b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts index 1a6c62b19..c30957a82 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts @@ -182,6 +182,65 @@ export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase { }; } +/** 반올림만 다른 같은 측점(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 +375,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/resources/tester/test_213_1_hidden_station_pvi.py b/resources/tester/test_213_1_hidden_station_pvi.py new file mode 100644 index 000000000..4e5480c8c --- /dev/null +++ b/resources/tester/test_213_1_hidden_station_pvi.py @@ -0,0 +1,214 @@ +"""213-1 — 한 자리에 측점 둘이 숨는 흠(엄상준_검증 d2245e3b · No.13+3). + +까닭: 자동설계 관 측점 263.206(0.001m) → 화면 저장이 관을 0.01m 로 반올림(263.21) → 측점은 +263.21 로 다시 섰는데 계획선 꼭짓점(base_pvi) · 편집 키는 옛 글자 「263.206」 그대로 → 새 측점 +편집 「263.210」과 0.004m 사이 두 꼭짓점(−15003% 계단). +고침: 계획선 공용 함수(파이썬 `snap_to_stations` ↔ TS `snapToStations`)가 측점 0.006m 안의 +꼭짓점 · 편집 키를 그 측점으로 모음(측점 위 것이 이김 · 측점 계획고 그대로) · 192 병합도 +옛 글자를 같은 측점으로 봄. 사용자 storage 는 읽기만 — 사본(tmp_path). +""" + +from __future__ import annotations + +import copy +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + AlignmentPolicy, + build_alignment, +) +from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile # noqa: E402 +from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402 + +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +SOURCE = ( + PROJECT_ROOT + / "storage/1/3/d2245e3b-0468-4a90-ac5c-50b2adcb56e7/B06_Section/longitudinal/longitudinal.json" +) +REL = "B06_Section/longitudinal/longitudinal.json" +needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="엄상준_검증 storage 없음") + +# 합성 — 격자 20m · 관 측점 263.206 → 263.21 반올림 · 끝 꼭짓점 1000.0(끝 측점 999.998) +STATIONS = [0.0, 20.0, 40.0, 263.21, 280.0, 300.0, 999.998] +BASE = [ + (0.0, 100.0), + (20.0, 99.0), + (40.0, 98.5), + (263.206, 90.0), + (280.0, 89.0), + (300.0, 88.0), + (1000.0, 60.0), +] +EDITS = { + "station_offsets": {"263.206": 1.5, "263.210": 0.9, "40.000": 0.2, "500.000": 0.3}, + "curve_radii": {"263.206": 300.0}, +} + + +def _synthetic() -> dict: + chainage = np.arange(0.0, 1000.0, 1.0) + return { + "base_s": np.array([s for s, _ in BASE]), + "base_z": np.array([z for _, z in BASE]), + "chainage": np.r_[chainage, 1000.0], + "ground": np.r_[100.0 - chainage * 0.04, 60.0], + "stations": [ + {"station_id": f"st{index}", "chainage_m": value} + for index, value in enumerate(STATIONS) + ], + } + + +def _python(edits: dict) -> dict: + data = _synthetic() + return build_alignment(**data, policy=AlignmentPolicy.from_dict({}), edits=copy.deepcopy(edits)) + + +def _close_pairs(alignment: dict, limit: float = 0.006) -> list[tuple[float, float]]: + pvi = [node["chainage_m"] for node in alignment["pvi"]] + return [(a, b) for a, b in zip(pvi, pvi[1:], strict=False) if b - a < limit] + + +def test_rounded_station_keys_collapse_onto_station(): + alignment = _python(EDITS) + pvi = {round(node["chainage_m"], 3): node for node in alignment["pvi"]} + assert _close_pairs(alignment) == [] + assert 263.206 not in pvi and 263.21 in pvi + # 측점 위 편집(263.210 = 0.9)이 이김 · 반경도 측점으로 옮김 · 측점에서 먼 키(500)는 그대로 + assert alignment["edits"]["station_offsets"] == {"263.210": 0.9, "40.000": 0.2, "500.000": 0.3} + assert alignment["edits"]["curve_radii"] == {"263.210": 300.0} + # 양 끝 꼭짓점은 그대로(끝 1000.0 을 측점 999.998 로 당기지 않음) + assert [node["chainage_m"] for node in alignment["base_pvi"]][-1] == 1000.0 + assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100 + + +def test_snapped_base_keeps_station_elevation(): + """편집 없는 측점 — 꼭짓점을 측점으로 옮겨도 그 측점 계획고(탄젠트)는 그대로.""" + alignment = _python({"station_offsets": {}, "curve_radii": {}}) + node = next(n for n in alignment["base_pvi"] if abs(n["chainage_m"] - 263.21) < 1e-9) + expected = float(np.interp(263.21, [s for s, _ in BASE], [z for _, z in BASE])) + assert node["elevation_m"] == pytest.approx(expected, abs=1e-6) + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { buildAlignment } from "./B05_Profile_UI_Profile_Alignment.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const result = buildAlignment(input.base, input.edits); +writeFileSync(process.argv[3], JSON.stringify(result)); +""" + + +@pytest.fixture(scope="module") +def ts_alignment(tmp_path_factory): + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + tmp = tmp_path_factory.mktemp("align213") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + data = _synthetic() + base = { + "policy": AlignmentPolicy.from_dict({}).as_dict(), + "onlyExplicitCurves": False, + "basePvi": [{"chainage_m": s, "elevation_m": z} for s, z in BASE], + "chainage": data["chainage"].tolist(), + "ground": data["ground"].tolist(), + "stations": data["stations"], + } + src, dst = tmp / "in.json", tmp / "out.json" + src.write_text(json.dumps({"base": base, "edits": EDITS}), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True + ) + return json.loads(dst.read_text(encoding="utf-8")) + + +def test_mirror_python_ts(ts_alignment): + py = _python(EDITS) + ts = ts_alignment + assert ts["edits"] == py["edits"] + assert len(ts["base_pvi"]) == len(py["base_pvi"]) + for a, b in zip(ts["base_pvi"], py["base_pvi"], strict=True): + assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-6) + assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6) + assert [n["chainage_m"] for n in ts["pvi"]] == pytest.approx( + [n["chainage_m"] for n in py["pvi"]], abs=1e-6 + ) + assert [n["elevation_m"] for n in ts["pvi"]] == pytest.approx( + [n["elevation_m"] for n in py["pvi"]], abs=1e-6 + ) + for a, b in zip(ts["stations"], py["stations"], strict=True): + assert a["plan_elevation_m"] == pytest.approx(b["plan_elevation_m"], abs=1e-6) + + +def _copy(tmp_path: Path) -> Path: + target = tmp_path / REL + target.parent.mkdir(parents=True) + shutil.copyfile(SOURCE, target) + return target + + +@needs_copy +def test_d2245e3b_no13_3_spike_gone(tmp_path): + data = json.loads(_copy(tmp_path).read_text(encoding="utf-8")) + edits = data["profile_alignment"]["edits"] + assert {"263.206", "263.210"} <= set(edits["station_offsets"]) # 재현 조건 + alignment, _ = rebuild_alignment_profile(data, edits) + assert _close_pairs(alignment) == [] + assert "263.206" not in alignment["edits"]["station_offsets"] + assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100 + + +@needs_copy +def test_d2245e3b_station_regen_drops_rounded_key(tmp_path): + """[측점 만들기] 로 관 측점 263.21 → 263.31 — 옛 글자 263.206 꼭짓점 · 편집도 함께 지워짐.""" + path = _copy(tmp_path) + data = json.loads(path.read_text(encoding="utf-8")) + irregular = [] + for station in data["stations"]: + if station.get("kind") != "irregular": + continue + item = copy.deepcopy(station) + if abs(item["chainage_m"] - 263.21) < 1e-6: + item["chainage_m"] = 263.31 + irregular.append(item) + _merge_irregular_into_longitudinal(tmp_path, REL, irregular) + saved = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"] + leftovers = [ + value + for value in [n["chainage_m"] for n in saved["base_pvi"]] + + [float(key) for key in saved["edits"]["station_offsets"]] + if 263.2 < value < 263.22 + ] + assert leftovers == [] + assert _close_pairs(saved, 0.05) == [] From 7cb9860500719720b8e6362ef98a65d1775260a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:26:23 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(CAD):=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=20=EC=9E=90=ED=8C=90=20=EB=8C=80=EC=9D=91=20?= =?UTF-8?q?=C2=B7=20M02=20=ED=99=94=EB=A9=B4=20=E2=86=B6=20=E2=86=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94(214-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ctrl+Z · Y · Shift+Z · S · A 를 자판 자리(code)로 판정 — 한글 자판 「ㅋ」·「ㅛ」 · Shift 대문자 대응 - 한글 조합 중에도 Ctrl 단축키는 먹음 - 제목줄 없는 M02 화면(양식 편집 · 구조물 카드)은 우하단 탐색 막대에 되돌리기 · 다시 하기 단추 - 포인터가 CAD 위에 오면 부모 입력 칸이 아닐 때 CAD 로 초점 - 새 시험 resources/tester/cad/ctrl-shortcut-hangul.test.ts Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../src/components/ViewControls.tsx | 21 +++- .../src/inputController/input-controller.ts | 16 +-- .../src/inputController/input-shortcut.ts | 24 ++++ .../tester/cad/ctrl-shortcut-hangul.test.ts | 113 ++++++++++++++++++ ui_template/cad_host/cad_host.ts | 6 + 5 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts create mode 100644 resources/tester/cad/ctrl-shortcut-hangul.test.ts 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 && ( + <> + + + + )}