From 6dd6ced454e8bc769ac7b685693b8d3cd1f2bc9c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:10:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20=EC=83=81=EC=84=B8=20=EB=8B=AB?= =?UTF-8?q?=ED=9E=8C=20=EC=98=81=EC=97=AD=20=EA=B3=A0=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=20=C2=B7=20=EB=91=98=EB=A0=88=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=20=EC=B9=B8=20=E2=86=92=20=EA=B7=B8=EB=9E=98=ED=94=84?= =?UTF-8?q?=20=C2=B7=20=ED=91=9C=20=EA=B0=95=EC=A1=B0=20=EB=B0=94=EA=B9=A5?= =?UTF-8?q?=20=ED=95=9C=20=EC=A4=84=20(259-2=20=C2=B7=20259-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 나눈 조각으로 면 찾기(열린 영역 제외) · 영역 안 누름 = 고름 · 채움 강조 · 면적 · 둘레 - 선 고름 경사비율 · 닫힌 선 둘레 · 띠 고름 값 글 - 표 칸 누름 = 저장된 조각 그래프 고름 · 띠 강조 + 값 글 - 고른 칸 묶음 바깥 한 줄(겹선 없음) · 줄 물들임 없앰 · 합친 묶음 이름 칸 키 맞춤 - 259-2 · 259-3 이 같은 파일(상세 표 배선)이라 한 커밋 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B06_Section/B06_Section_UI_Cross_Detail.ts | 69 ++++++++-- .../B06_Section_UI_Cross_Detail_Geom.ts | 85 ++++++++++++- .../B06_Section_UI_Cross_Detail_Pick.ts | 46 +++++++ .../B06_Section_UI_Cross_Detail_Select.ts | 120 ++++++++++++++++-- .../B06_Section_UI_Cross_Detail_Table.ts | 58 ++++++++- .../B06_Section_UI_Style_Cross_Detail.css | 37 +++++- resources/tester/helper_259_detail_region.cjs | 89 +++++++++++++ resources/tester/test_150_detail_pick.py | 9 +- resources/tester/test_164_detail_zoom.py | 16 ++- resources/tester/test_209_1_detail_select.py | 4 +- resources/tester/test_259_detail_region.py | 90 ++++++++++++- 11 files changed, 575 insertions(+), 48 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 260405b9b..7ded9b978 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -24,7 +24,12 @@ import type { SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { CROSS_PAD, inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common"; -import { DETAIL_TARGETS, highlightDetail, presentKeys } from "./B06_Section_UI_Cross_Detail_Pick"; +import { + DETAIL_TARGETS, + highlightDetail, + outlinePicked, + presentKeys, +} from "./B06_Section_UI_Cross_Detail_Pick"; import { attachSvgZoomPan, type ZoomPanHandle, @@ -132,18 +137,25 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT if (repick.has(key)) td.dataset.repick = ""; return mark(td, { cell: key, row, col }, isAdded(key)); }; - // 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) + // 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) · 합친 묶음 = 덮는 줄 키 모두(259-3) const nameCell = ( label: string | null, span: { col?: number; row?: number } | undefined, key: string | null, row: string, slot: number, + keys?: Array, ): HTMLElement => { const added = row.startsWith("r"); - const data = { row, label: added ? `${row}:${slot}` : undefined }; + const data = { + row, + label: added ? `${row}:${slot}` : undefined, + keys: keys && keys.length > 1 ? keys.filter(Boolean).join(" ") : undefined, + }; return mark(cell("th", label ?? "", span, key), data, added); }; + const groupKeys = (slots: typeof layout.left, index: number) => + slots.slice(index, index + groupSpan(slots, index)).map((slot) => slot[2]); const table = document.createElement("table"); table.className = "b06-detail__qtable"; const titleRow = table.insertRow(); @@ -158,13 +170,31 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT const row = table.insertRow(); // 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) · 묶음 = 하위 줄만큼(179-4) if (groupL !== null && subL !== null) - row.append(nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)); + row.append( + nameCell( + groupL, + { row: groupSpan(layout.left, index) }, + keyL, + id, + 0, + groupKeys(layout.left, index), + ), + ); else if (groupL !== null) row.append(nameCell(groupL, { col: 2 }, keyL, id, 0)); if (subL !== null) row.append(nameCell(subL, undefined, keyL, id, 0)); row.append(valueCell(keyL, id)); const [groupM, subM, keyM] = layout.middle[index]; if (groupM !== null && subM !== null) - row.append(nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)); + row.append( + nameCell( + groupM, + { row: groupSpan(layout.middle, index) }, + keyM, + id, + 1, + groupKeys(layout.middle, index), + ), + ); else if (groupM !== null) row.append(nameCell(groupM, { col: 2 }, keyM, id, 1)); if (subM !== null) row.append(nameCell(subM, undefined, keyM, id, 1)); row.append(valueCell(keyM, id)); @@ -289,24 +319,35 @@ export function openCrossDetail(section: CrossSection): void { tableBox.className = "b06-detail__table"; body.append(cad, note, tableBox); - // 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) + // 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) · + // 저장된 조각 · 띠 칸 = 그래프에서 고름 + 값 글(259-2) · 고른 칸 묶음 = 바깥 한 줄(259-3) let picked: string | null = null; const showPicked = (): void => { const svg = cad.querySelector("svg"); const present = svg ? presentKeys(svg, state.section.design) : new Set(); - if (picked && !present.has(picked)) picked = null; - tableBox.querySelectorAll("[data-key]").forEach((el) => { - el.classList.toggle("is-picked", el.dataset.key === picked); - el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? "")); + const graph = picked ? tablePick.graph(picked) : null; + if (picked && !graph && !present.has(picked)) picked = null; + tableBox.querySelectorAll("[data-key], [data-cell]").forEach((el) => { + const keys = (el.dataset.keys ?? el.dataset.key ?? el.dataset.cell ?? "").split(" "); + el.classList.toggle("is-picked", picked !== null && keys.includes(picked)); + if (el.dataset.key) + el.toggleAttribute( + "data-empty", + !present.has(el.dataset.key) && !tablePick.graph(el.dataset.key), + ); }); - if (svg) highlightDetail(svg, state.section.design, picked); + outlinePicked(tableBox); + const band = graph && "band" in graph ? graph.band : graph ? null : picked; + if (svg) highlightDetail(svg, state.section.design, band); }; tableBox.addEventListener("click", (event) => { - const el = (event.target as HTMLElement).closest("[data-key]"); - const key = el?.dataset.key; - if (!key || el.hasAttribute("data-empty")) return; + const el = (event.target as HTMLElement).closest("[data-key], [data-cell]"); + const key = el?.dataset.key ?? el?.dataset.cell; + if (!el || !key) return; + if (!tablePick.graph(key) && (!el.dataset.key || el.hasAttribute("data-empty"))) return; picked = picked === key ? null : key; showPicked(); + tablePick.showCell(picked); }); // 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4) diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts index 3f15d056d..8e7839a35 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts @@ -5,7 +5,7 @@ * 짝(① 파이썬 · 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`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산. + * 나눔(`splitAtCrossings`) · 닫힌 영역(`findFaces` · 259-2)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산. * ========================================================================== */ import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; @@ -227,6 +227,89 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] { return out; } +/* ── 닫힌 영역(259-2 · 화면 몫) ─────────────────────────────────────────── */ + +/** 나눈 조각으로 둘러싸인 면 하나 — 둘레 조각 번호 · 고리(m) · 넓이(㎡). */ +export interface Face { + edges: number[]; + ring: Pt[]; + area: number; +} + +/** 꺾은선이 그 끝에서 떠나는 방향(라디안) — 아주 짧은 첫 토막은 건너뜀. */ +function leaving(points: Pt[]): number { + const [x0, y0] = points[0]; + const next = points.find((p) => dist(p, points[0]) > 1e-6) ?? points[points.length - 1]; + return Math.atan2(next[1] - y0, next[0] - x0); +} + +/** + * 조각(변)으로 된 평면 그래프의 면 — 끝이 `TOUCH_M` 안이면 같은 마디 · 열린 끝 조각(마디 차수 1)은 + * 거듭 지움(열린 영역 제외) · 각 반변에서 도착 마디의 시계 방향 다음 변으로 돌아 면을 셈 · + * 넓이가 양(반시계)인 것만 = 바깥 면 뺌. + * ponytail: 안에 떠 있는 섬(다리 없음)은 바깥 면의 구멍으로 안 뺌 — 필요하면 포함 판정 더함. + */ +export function findFaces(pieces: SplitPiece[]): Face[] { + const nodes: Pt[] = []; + const nodeOf = (p: Pt): number => { + const found = nodes.findIndex((q) => dist(p, q) <= TOUCH_M); + return found >= 0 ? found : nodes.push(p) - 1; + }; + const ends = pieces.map((piece) => [ + nodeOf(piece.points[0]), + nodeOf(piece.points[piece.points.length - 1]), + ]); + const alive = ends.map(([a, b]) => a !== b); + for (let changed = true; changed;) { + const degree = new Array(nodes.length).fill(0); + ends.forEach(([a, b], i) => alive[i] && (degree[a]++, degree[b]++)); + changed = false; + ends.forEach(([a, b], i) => { + if (alive[i] && (degree[a] < 2 || degree[b] < 2)) [alive[i], changed] = [false, true]; + }); + } + // 반변 h = 2i(앞 → 뒤) · 2i+1(뒤 → 앞) + const path = (h: number): Pt[] => { + const points = pieces[h >> 1].points; + return h & 1 ? [...points].reverse() : points; + }; + const head = (h: number): number => ends[h >> 1][h & 1 ? 0 : 1]; + const out = nodes.map(() => [] as Array<[number, number]>); + ends.forEach(([a, b], i) => { + if (!alive[i]) return; + out[a].push([leaving(path(2 * i)), 2 * i]); + out[b].push([leaving(path(2 * i + 1)), 2 * i + 1]); + }); + out.forEach((list) => list.sort((p, q) => p[0] - q[0])); + const next = (h: number): number => { + const list = out[head(h)]; + const at = list.findIndex(([, e]) => e === (h ^ 1)); + return list[(at - 1 + list.length) % list.length][1]; + }; + const faces: Face[] = []; + const seen = new Set(); + ends.forEach((_, i) => { + if (!alive[i]) return; + for (const start of [2 * i, 2 * i + 1]) { + if (seen.has(start)) continue; + const cycle: number[] = []; + for (let h = start; !seen.has(h) && cycle.length <= 2 * pieces.length; h = next(h)) { + seen.add(h); + cycle.push(h); + } + const ring = cycle.flatMap((h) => path(h).slice(1)); + let twice = 0; + ring.forEach((p, k) => { + const q = ring[(k + 1) % ring.length]; + twice += p[0] * q[1] - q[0] * p[1]; + }); + if (twice / 2 > 1e-9) + faces.push({ edges: [...new Set(cycle.map((h) => h >> 1))], ring, area: twice / 2 }); + } + }); + return faces; +} + /** 조각 → 저장 꼴(두 끝 · 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]; diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts index 01f3415c9..f76ffb8cf 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts @@ -6,6 +6,7 @@ * (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려 * `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리. * 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖. + * 고른 칸 묶음 = 바깥 한 줄(`outlinePicked` · 259-3). * ponytail: 판정 선의 구조물 트림 여부는 복제에서 모름 → 자리로만 가름(트림 선은 놓인 구역을 따름). * ========================================================================== */ @@ -75,3 +76,48 @@ export function presentKeys(svg: Element, design: CrossDesign | undefined): Set< Object.keys(DETAIL_TARGETS).filter((key) => targetsOf(svg, design, key).length > 0), ); } + +/* ── 표 강조 바깥 한 줄(259-3) ──────────────────────────────────────────── */ + +/** 직사각형들(좌 · 위 · 우 · 아래)의 합친 모양 바깥 줄 — 격자로 나눠 덮인 칸과 안 덮인 칸 사이 변만(SVG path). */ +export function unionOutline(rects: number[][]): string { + const xs = [...new Set(rects.flatMap((r) => [r[0], r[2]]))].sort((a, b) => a - b); + const ys = [...new Set(rects.flatMap((r) => [r[1], r[3]]))].sort((a, b) => a - b); + const covered = (i: number, j: number): boolean => + i >= 0 && + j >= 0 && + i + 1 < xs.length && + j + 1 < ys.length && + rects.some((r) => r[0] <= xs[i] && xs[i + 1] <= r[2] && r[1] <= ys[j] && ys[j + 1] <= r[3]); + const parts: string[] = []; + for (let i = 0; i + 1 < xs.length; i++) + for (let j = 0; j + 1 < ys.length; j++) { + if (!covered(i, j)) continue; + const [x0, x1, y0, y1] = [xs[i], xs[i + 1], ys[j], ys[j + 1]]; + if (!covered(i, j - 1)) parts.push(`M${x0} ${y0}H${x1}`); + if (!covered(i, j + 1)) parts.push(`M${x0} ${y1}H${x1}`); + if (!covered(i - 1, j)) parts.push(`M${x0} ${y0}V${y1}`); + if (!covered(i + 1, j)) parts.push(`M${x1} ${y0}V${y1}`); + } + return parts.join(""); +} + +/** 고른 칸(`.is-picked`) 묶음에 바깥 한 줄 — 칸마다 테두리를 그리면 붙은 칸에 겹선이 생김. */ +export function outlinePicked(box: HTMLElement): void { + box.querySelector(".b06-detail__pick-outline")?.remove(); + const cells = [...box.querySelectorAll(".is-picked")]; + if (!cells.length) return; + const base = box.getBoundingClientRect(); + const rects = cells.map((cell) => { + const r = cell.getBoundingClientRect(); + return [r.left - base.left, r.top - base.top, r.right - base.left, r.bottom - base.top].map( + (v) => Math.round(v), + ); + }); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "b06-detail__pick-outline"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", unionOutline(rects)); + svg.append(path); + box.append(svg); +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index b3d8442c4..98f4d5eba 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -3,7 +3,10 @@ * [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로 * 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 · * Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴): - * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」). + * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」). + * 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각) + * 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움. + * 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`). * 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀). * ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때. * ========================================================================== */ @@ -17,16 +20,21 @@ import { pieceLabel, } from "./B06_Section_UI_Cross_Design_Pick"; import { + findFaces, + LINE_TOLERANCE_M, pathLength, pieceSource, ringArea, splitAtCrossings, + type Face, 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"; +/** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */ +export const FACE_HIT_CLASS = "b06-detail__face"; /** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */ export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt { @@ -124,16 +132,45 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie to: { offset: to[0], elevation: to[1] }, structure: piece.line === "structure", }); + const horizontal = Math.abs(to[0] - from[0]); + const height = Math.abs(to[1] - from[1]); + const grade = horizontal > 0 ? `${((height / horizontal) * 100).toFixed(1)}%` : "수직"; 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` + `${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m · 경사비율 ${grade}\n` + + `수평 ${horizontal.toFixed(2)}m · 높이 ${height.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)}㎡`) + (area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`) + ); +} + +/** 고른 영역 값 두 줄(259-2) — 넓이 합 / 둘레(고른 둘레 조각 길이 합 · 붙은 영역은 맞닿은 변 뺌). */ +export function regionLabel(faces: Face[], pieces: SplitPiece[]): string { + const area = faces.reduce((sum, face) => sum + face.area, 0); + const around = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0); + const count = faces.length > 1 ? `${faces.length}개 · ` : ""; + return `영역 ${count}면적 ${area.toFixed(2)}㎡\n둘레 ${around.toFixed(2)}m`; +} + +/** 영역 여럿 → 둘레 조각 — 둘 이상이 맞닿은 변은 짝수 번 나와 빠짐. */ +export function regionEdges(faces: Face[]): number[] { + const count = new Map(); + for (const face of faces) + for (const edge of face.edges) count.set(edge, (count.get(edge) ?? 0) + 1); + return [...count].filter(([, n]) => n % 2).map(([edge]) => edge); +} + +/** 그림 조각이 저장된 조각(두 끝 · 어느 방향이든)과 같은 자리인가(259-2 표 칸 → 그래프). */ +export function samePiece(piece: SplitPiece, stored: CrossPickPiece): boolean { + if (piece.line !== stored.line) return false; + const near = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M; + const [a, b] = [piece.points[0], piece.points[piece.points.length - 1]]; + return ( + (near(a, stored.start) && near(b, stored.end)) || (near(a, stored.end) && near(b, stored.start)) ); } @@ -156,6 +193,10 @@ export interface DetailSelect { setMode: (kind: "area" | "length" | null) => void; source: (kind: "area" | "length") => ReturnType; clear: () => void; + /** 저장된 조각을 그림에서 고름(259-2 표 칸 누름) · null = 비움. */ + choose: (stored: CrossPickPiece[] | null) => void; + /** 고름 없이 값 글만(띠 · 259-2) — 다음 누름에 지워짐. */ + note: (text: string | null) => void; detach: () => void; } @@ -171,11 +212,18 @@ export function attachDetailSelect(input: { picked: (additive: boolean) => void; /** [칸에 넣기] 단추. */ commit: () => void; + /** 면적 고르기 중 그 자리에 면적 띠가 있나 — 있으면 영역 대신 띠(표가 맡음). */ + band?: (event: MouseEvent) => boolean; }): DetailSelect { const { cad } = input; let pieces: SplitPiece[] = []; let hits: SVGPolylineElement[] = []; + let faces: Face[] = []; + let faceEls: SVGPolygonElement[] = []; let selected: number[] = []; + /** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */ + let chosenFaces: number[] = []; + let noteText: string | null = null; let mode: "area" | "length" | null = null; const label = document.createElement("span"); @@ -193,8 +241,21 @@ export function attachDetailSelect(input: { 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; + const same = (edges: number[]) => + edges.length === selected.length && edges.every((edge) => selected.includes(edge)); + const filled = chosenFaces.length + ? chosenFaces + : faces.flatMap((face, i) => (same(face.edges) ? [i] : [])); + faceEls.forEach((el, i) => el.classList.toggle("is-selected", filled.includes(i))); + label.textContent = + noteText ?? + (filled.length + ? regionLabel( + filled.map((i) => faces[i]), + chosen, + ) + : selectionLabel(input.design(), chosen)); + label.hidden = !label.textContent; put.hidden = !mode || !selectionSource(mode, chosen); // [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김 const bar = cad.querySelector(".b06-cross-card__zoom"); @@ -202,16 +263,29 @@ export function attachDetailSelect(input: { }; 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"; + const target = event.target as Element; + const hit = target.closest?.(`.${PIECE_HIT_CLASS}`); + const shift = event.shiftKey || event.ctrlKey || event.metaKey; + const additive = shift || mode === "area"; + noteText = null; if (!hit) { - if (!additive && !mode && selected.length) { - selected = []; + const face = target.closest?.(`.${FACE_HIT_CLASS}`); + if (face && !(mode === "area" && input.band?.(event))) { + // 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) + event.stopPropagation(); + const index = faceEls.indexOf(face as SVGPolygonElement); + chosenFaces = toggleSelection(chosenFaces, index, shift); + selected = regionEdges(chosenFaces.map((i) => faces[i])); show(); + input.picked(true); + return; } + if (!additive && !mode) [selected, chosenFaces] = [[], []]; + show(); return; } event.stopPropagation(); + chosenFaces = []; selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive); show(); input.picked(additive); @@ -226,7 +300,19 @@ export function attachDetailSelect(input: { const layer = cad.querySelector("g.b06-chart__plot-layer"); pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : []; selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : [])); + chosenFaces = []; + faces = findFaces(pieces); const toPx = axes ? mToPx(axes) : null; + // 영역 판정 면 먼저(밑) → 조각 판정 선(위) + faceEls = toPx + ? faces.map((face) => { + const el = document.createElementNS(SVG_NS, "polygon"); + el.setAttribute("points", face.ring.map(toPx).join(" ")); + el.setAttribute("class", FACE_HIT_CLASS); + layer?.append(el); + return el; + }) + : []; hits = toPx ? pieces.map((piece) => { const hit = document.createElementNS(SVG_NS, "polyline"); @@ -248,7 +334,19 @@ export function attachDetailSelect(input: { selected.map((i) => pieces[i]), ), clear() { - selected = []; + [selected, chosenFaces] = [[], []]; + show(); + }, + choose(stored) { + chosenFaces = []; + noteText = null; + selected = stored + ? pieces.flatMap((piece, i) => (stored.some((s) => samePiece(piece, s)) ? [i] : [])) + : []; + show(); + }, + note(text) { + noteText = text; show(); }, detach() { diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts index 24bf4203d..65cc99823 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -9,6 +9,8 @@ * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) — * 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록. * 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기]. + * 닫힌 영역 안 누름 = 그 영역 둘레 조각(259-2) · 면적 고르기 중 띠 자리는 띠가 앞. + * 표 칸 누름(259-2) = 저장된 조각을 그림에서 다시 고름 · 띠 칸 = 값 글(`cellGraph` · `bandLabel`). * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; @@ -19,7 +21,7 @@ import type { CrossDetailTable, CrossSection, } from "./B06_Section_Api_Fetch"; -import type { SectionSample } from "./B06_Section_Api_Types"; +import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom"; import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select"; @@ -173,6 +175,32 @@ export function areaSourceOf(target: Element): CrossCellSource | null { return band ? { src: "area", key: band[1] } : null; } +/** 그 칸이 그래프에서 무엇을 가리키나(259-2) — 저장된 조각 · 띠 키(저장 띠 출처 → 칸 키) · 없음. */ +export function cellGraph( + table: CrossDetailTable, + key: string, +): { pieces: CrossPickPiece[] } | { band: string } | null { + const source = table.cells[key]; + if (source && "pieces" in source) return { pieces: source.pieces }; + if (source && source.src === "area" && "key" in source) return { band: source.key }; + return null; +} + +const BAND_NAME: Readonly> = { + cut_soil: "깍기(토사)", + cut_rock: "깍기(암석)", + ditch_soil: "측구(토사)", + ditch_rock: "측구(암석)", + embankment: "쌓기", + spoil_fill: "사토장", +}; + +/** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */ +export function bandLabel(key: string, design: CrossDesign | undefined): string | null { + const value = BAND_NAME[key] ? localCellValue({ src: "area", key }, design) : null; + return value === null ? null : `${BAND_NAME[key]} 면적 ${value.toFixed(2)}㎡`; +} + /* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */ const AREA_DESIGN: Readonly> = { @@ -280,7 +308,7 @@ export interface DetailTableHost { let menu: ReturnType | null = null; const PICK_HINT = { - area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소", + area: "면적 띠를 누름 · 또는 닫힌 영역 안 · 선들을 누름 → Enter · Esc 취소", length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소", } as const; @@ -289,6 +317,10 @@ const PICK_HINT = { export function attachDetailTable(host: DetailTableHost): { detach: () => void; redraw: () => void; + /** 칸 그래프 짝(259-2) — 저장된 조각 · 띠 키 · 없음. */ + graph: (key: string) => ReturnType; + /** 표 칸 누름(259-2) — 저장된 조각을 그림에서 고름 · 띠면 값 글 · null = 비움. */ + showCell: (key: string | null) => void; } { menu ??= createMapContextMenu("b06-detail"); const ctxMenu = menu; @@ -323,6 +355,7 @@ export function attachDetailTable(host: DetailTableHost): { 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); + if ("key" in source) select.note(bandLabel(source.key, section.design)); // 띠도 값 글(259-2) edit((table) => setCell(table, key, source)); }; const commitSelection = (): void => { @@ -354,14 +387,17 @@ export function attachDetailTable(host: DetailTableHost): { if (picking?.kind === "length" && !additive) commitSelection(); }, commit: commitSelection, + band: (event) => !!bandAt(event), }); - // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음 + const bandAt = (event: MouseEvent): Element | undefined => + document + .elementsFromPoint(event.clientX, event.clientY) + .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); + // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 조각 누름은 위가 맡음 const onPick = (event: MouseEvent): void => { 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(AREA_PICK_SELECTOR)); + const hit = bandAt(event); const source = hit ? areaSourceOf(hit) : null; if (!source) return; event.stopPropagation(); @@ -426,5 +462,15 @@ export function attachDetailTable(host: DetailTableHost): { tableBox.removeEventListener("keydown", onEnter); }, redraw: () => select.redraw(), + graph: (key) => cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key), + showCell: (key) => { + const target = key + ? cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key) + : null; + select.choose(target && "pieces" in target ? target.pieces : null); + // 저장 조각 = 조각 값 글 · 저장 띠 = 그 띠 · 저장 없음 = 칸 키 띠 + const band = !target ? key : "band" in target ? target.band : null; + select.note(band ? bandLabel(band, host.section().design) : null); + }, }; } diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index 2f14f0637..be3ea6726 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -28,6 +28,8 @@ .b06-detail__table { flex: none; + /* 고른 칸 바깥 줄(259-3) 자리 기준 */ + position: relative; } /* 그림 칸 = 테마 색(173-4 · 카드와 같은 바탕) · 그림은 칸 크기로 새로 그려 가득 */ @@ -81,7 +83,7 @@ color: var(--color-text); } -/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 · 줄 붉게 */ +/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 붉게 · 테두리는 묶음 바깥 한 줄(259-3) */ .b06-detail__qtable [data-key] { cursor: pointer; } @@ -92,12 +94,23 @@ .b06-detail__qtable .is-picked { background: color-mix(in srgb, var(--color-danger) 18%, transparent); - box-shadow: inset 0 0 0 1px var(--color-danger); color: var(--color-danger); } -.b06-detail__qtable tr:has(.is-picked) { - background: color-mix(in srgb, var(--color-danger) 5%, transparent); +.b06-detail__pick-outline { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.b06-detail__pick-outline path { + fill: none; + stroke: var(--color-danger); + stroke-width: 2; + stroke-linecap: square; } /* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */ @@ -188,6 +201,22 @@ stroke-width: 6; } +/* 닫힌 영역 판정 면(259-2) — 투명 채움으로 누름 · 고른 영역 = 붉은 채움 */ +.b06-detail__face { + fill: transparent; + stroke: none; + pointer-events: fill; + cursor: pointer; +} + +.b06-detail__face:hover { + fill: color-mix(in srgb, var(--color-accent) 8%, transparent); +} + +.b06-detail__face.is-selected { + fill: color-mix(in srgb, var(--color-danger) 22%, transparent); +} + /* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */ .b06-detail__commit { width: auto; diff --git a/resources/tester/helper_259_detail_region.cjs b/resources/tester/helper_259_detail_region.cjs index 61139b6dd..a17c883c6 100644 --- a/resources/tester/helper_259_detail_region.cjs +++ b/resources/tester/helper_259_detail_region.cjs @@ -130,4 +130,93 @@ out.rockSource = S.selectionSource("length", [rockPieces[1]]); out.rockLocal = T.localCellValue(out.rockSource, design, samples); out.rockLength = G.pathLength(rockPieces[1].points); +/* ③ 닫힌 영역(259-2) — 면 찾기 · 열린 영역 제외 · 영역 고름 값 · 붙은 두 영역 = 맞닿은 변 뺌 */ +const faces = G.findFaces(pieces); +out.faces = faces.map((face) => ({ + area: face.area, + lines: face.edges.map((i) => pieces[i].line).sort(), +})); +const one = faces.map((face) => + S.regionLabel( + [face], + face.edges.map((i) => pieces[i]), + ), +); +out.faceLabels = one; +const union = S.regionEdges(faces); +const unionPieces = union.map((i) => pieces[i]); +out.union = { + lines: unionPieces.map((p) => p.line).sort(), + label: S.regionLabel(faces, unionPieces), + selectLabel: S.selectionLabel(design, unionPieces), + source: S.selectionSource("area", unionPieces), +}; +out.union.local = T.localCellValue(out.union.source, design, samples); +// 열린 원지반만 = 면 없음 +out.openFaces = G.findFaces(pieces.filter((p) => p.line === "ground")).length; +// 정사각형 하나 + 매달린 꼬리 — 꼬리 지우고 면 하나(넓이 1) +out.square = G.findFaces([ + { + line: "ground", + points: [ + [0, 0], + [1, 0], + ], + }, + { + line: "design", + points: [ + [1, 0], + [1, 1], + [0, 1], + ], + }, + { + line: "rock", + points: [ + [0, 1], + [0, 0], + ], + }, + { + line: "spoil", + points: [ + [1, 1], + [3, 3], + ], + }, +]).map((face) => ({ area: face.area, edges: face.edges.sort() })); + +/* ④ 표 칸 → 그래프(259-2) — 저장된 조각 짝 · 칸 그래프 짝 · 띠 값 글 */ +const stored = G.pieceSource(rockPieces[1]); +out.same = { + forward: S.samePiece(rockPieces[1], stored), + reverse: S.samePiece(rockPieces[1], { ...stored, start: stored.end, end: stored.start }), + otherLine: S.samePiece(rockPieces[1], { ...stored, line: "ground" }), + otherPiece: S.samePiece(rockPieces[0], stored), +}; +const table = { + cells: { + fence: out.rockSource, + cut_soil: { src: "area", key: "cut_rock" }, + "r:r1:0": { src: "text", text: "글" }, + }, + rows: [], + columns: [], +}; +out.cellGraph = ["fence", "cut_soil", "r:r1:0", "embankment"].map((key) => T.cellGraph(table, key)); +out.bandLabel = [ + T.bandLabel("cut_soil", { cut_soil_area_m2: 1.234 }), + T.bandLabel("cut_soil", {}), + T.bandLabel("ground", { cut_soil_area_m2: 1 }), +]; + +/* ⑤ 표 강조 바깥 한 줄(259-3) — 합친 묶음 이름 칸(두 줄) + 둘째 줄 이름 · 값 = ㄴ 모양 */ +const P = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Pick.ts")); +out.outline = P.unionOutline([ + [0, 0, 10, 20], + [10, 10, 20, 20], + [20, 10, 30, 20], +]); + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_150_detail_pick.py b/resources/tester/test_150_detail_pick.py index f9df29850..717cf926c 100644 --- a/resources/tester/test_150_detail_pick.py +++ b/resources/tester/test_150_detail_pick.py @@ -80,8 +80,9 @@ def test_상세_표_칸_키_누름_배선(): ) as f: src = f.read() assert "element.dataset.key = key" in src - assert "highlightDetail(svg, state.section.design, picked)" in src - assert 'closest("[data-key]")' in src + # 259-2 — 저장 띠 출처면 그 띠 · 저장 조각이면 강조 없음(그래프 고름) · 값 칸(data-cell)도 누름 + assert "highlightDetail(svg, state.section.design, band)" in src + assert 'closest("[data-key], [data-cell]")' in src def test_상세_큰_창_공용_막_그림_글_표_순서(): @@ -93,6 +94,8 @@ def test_상세_큰_창_공용_막_그림_글_표_순서(): css = f.read().replace(chr(13), "") assert "wide: true" in src and "backdropClass" not in src assert "body.append(cad, note, tableBox)" in src - assert "pointer-events: none" not in css and "b06-detail__backdrop" not in css + # 막 없음 — 259-3 표 강조 바깥 줄(누름 통과)만 pointer-events: none + backdrop = css.split(".b06-detail__pick-outline {")[0] + assert "pointer-events: none" not in backdrop and "b06-detail__backdrop" not in css assert ".ui-modal.b06-detail > .ui-modal__body {\n overflow: hidden;" in css assert "flex: 1;\n min-height: 0;" in css and "height: 100%;" in css # 173-4 칸 가득 diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index e85518359..1b14444f5 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -105,10 +105,12 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸(): ".b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);", ".b06-detail__cad .b06-chart__spoil-fill.is-active {\n stroke: var(--color-danger);", ".b06-detail__qtable .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%", - ".b06-detail__qtable tr:has(.is-picked) {", ".b06-detail__qtable [data-key][data-empty] {\n cursor: default;", ): assert selector in css, selector + # 259-3 — 줄 물들임 · 칸마다 테두리 없앰(묶음 바깥 한 줄) + assert "tr:has(.is-picked)" not in css + assert "inset 0 0 0 1px var(--color-danger)" not in css assert "royal-amethyst" not in css and "--color-warning);" not in css.split("/* 그림 강조")[1] assert ( ".b06-detail__qtable [data-key]:hover {\n background: color-mix(in srgb, var(--color-danger) 8%" @@ -118,11 +120,13 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸(): def test_빈칸_누름_없음_이름_칸_첫_키(): src = _read("B06_Section_UI_Cross_Detail.ts") - assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src - assert 'if (!key || el.hasAttribute("data-empty")) return;' in src - # 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키 - assert "nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)" in src - assert "nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)" in src + assert "!present.has(el.dataset.key) && !tablePick.graph(el.dataset.key)" in src + assert '(!el.dataset.key || el.hasAttribute("data-empty"))) return;' in src + # 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키 · + # 259-3 덮는 줄 키 모두(data-keys) + flat = " ".join(src.split()) + assert "nameCell( groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0," in flat + assert "nameCell( groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1," in flat def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만(): diff --git a/resources/tester/test_209_1_detail_select.py b/resources/tester/test_209_1_detail_select.py index b695354c9..9ef743826 100644 --- a/resources/tester/test_209_1_detail_select.py +++ b/resources/tester/test_209_1_detail_select.py @@ -117,11 +117,11 @@ def test_원지반_포함_닫힌_영역_넓이(out): 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["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡ · 둘레 {length:.2f}m" 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" + f"원지반선 길이 {2 * CROSS_X:.2f}m · 경사비율 0.0%\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m" ) diff --git a/resources/tester/test_259_detail_region.py b/resources/tester/test_259_detail_region.py index 4a0c3b66e..c5c4351bc 100644 --- a/resources/tester/test_259_detail_region.py +++ b/resources/tester/test_259_detail_region.py @@ -6,6 +6,9 @@ 거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음. - 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 · 암 프리셋 아니면 다시 고름. + - 259-2 닫힌 영역 — 면 찾기(열린 영역 · 매달린 꼬리 제외) · 영역 고름 값(면적 · 둘레) · 붙은 영역 = + 맞닿은 변 뺌 · 서버 거울 · 표 칸 → 저장된 조각 · 띠 짝 · 띠 값 글. + - 259-3 표 강조 — 고른 칸 묶음 바깥 한 줄(칸 사이 겹선 없음). ./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q """ @@ -13,6 +16,7 @@ import json import os import subprocess +from math import hypot from pathlib import Path import pytest @@ -45,6 +49,7 @@ AXES = { "exaggeration": 2, } ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset +GROUND_X = 2 + 4 / 3 # 경사가 원지반 100 m 와 만나는 offset pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" @@ -98,7 +103,9 @@ def test_암_경계선이_설계선과_만나는_점에서_나뉨(out): def test_암_경계선_고름_값_저장_꼴(out): length = 2 * ROCK_X assert out["rockLength"] == pytest.approx(length, abs=1e-3) - assert out["rockLabel"] == f"암 경계선 길이 {length:.2f}m\n수평 {length:.2f}m · 높이 0.00m" + assert out["rockLabel"] == ( + f"암 경계선 길이 {length:.2f}m · 경사비율 0.0%\n수평 {length:.2f}m · 높이 0.00m" + ) source = out["rockSource"] assert source["src"] == "length" and source["pieces"][0]["line"] == "rock" patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}}) @@ -113,3 +120,84 @@ def test_암_경계선_서버_거울(out): soil = {**DESIGN, "geometry_preset": "soil"} assert pick_lines(soil, SAMPLES)["rock"] == [] assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True) + + +# ── 259-2 닫힌 영역 · 영역 고름 · 면적 ──────────────────────────────────── + +SLOPE_1M = hypot(4 / 6, 1.0) # 경사 조각 높이 1 m 길이 +FACE_TOP = (2 * GROUND_X + 2 * ROCK_X) / 2 # 원지반 100 ~ 암 99 사이 사다리꼴 +FACE_LOW = (2 * ROCK_X + 4) / 2 # 암 99 ~ 바닥 98 사이 사다리꼴 + + +def test_면_찾기_열린_영역_제외(out): + faces = sorted(out["faces"], key=lambda f: -f["area"]) + assert [f["area"] for f in faces] == pytest.approx([FACE_TOP, FACE_LOW], abs=1e-6) + assert faces[0]["lines"] == ["design", "design", "ground", "rock"] + assert faces[1]["lines"] == ["design", "design", "design", "rock"] + assert out["openFaces"] == 0 # 원지반만 = 열림 + assert out["square"] == [{"area": pytest.approx(1.0), "edges": [0, 1, 2]}] # 꼬리 지움 + + +def test_영역_고름_면적_둘레(out): + top = 2 * GROUND_X + 2 * ROCK_X + 2 * SLOPE_1M + low = 2 * ROCK_X + 4 + 2 * SLOPE_1M + assert sorted(out["faceLabels"]) == sorted( + [ + f"영역 면적 {FACE_TOP:.2f}㎡\n둘레 {top:.2f}m", + f"영역 면적 {FACE_LOW:.2f}㎡\n둘레 {low:.2f}m", + ] + ) + + +def test_붙은_두_영역_맞닿은_변_뺌(out): + union = out["union"] + assert union["lines"] == ["design"] * 5 + ["ground"] # 암 경계선 빠짐 + area = FACE_TOP + FACE_LOW + around = 2 * GROUND_X + 4 + 4 * SLOPE_1M + assert union["label"] == f"영역 2개 · 면적 {area:.2f}㎡\n둘레 {around:.2f}m" + assert ( + union["selectLabel"] + == f"6개 · 길이 합 {around:.2f}m\n면적 {area:.2f}㎡ · 둘레 {around:.2f}m" + ) + assert union["local"] == pytest.approx(area, abs=1e-3) + number, _text, again = cell_value(union["source"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(union["local"], abs=1e-6) + + +# ── 259-2 표 칸 → 그래프 ───────────────────────────────────────────────── + + +def test_표_칸_저장된_조각을_그래프에서_고름(out): + assert out["same"] == { + "forward": True, + "reverse": True, + "otherLine": False, + "otherPiece": False, + } + pieces, band, text, none = out["cellGraph"] + assert pieces == {"pieces": out["rockSource"]["pieces"]} + assert band == {"band": "cut_rock"} + assert text is None and none is None + + +def test_띠_값_글(out): + assert out["bandLabel"] == ["깍기(토사) 면적 1.23㎡", None, None] + + +# ── 259-3 표 강조 바깥 한 줄 ────────────────────────────────────────────── + + +def test_고른_칸_묶음_바깥_한_줄(out): + parts = {"M" + p for p in out["outline"].split("M") if p} + assert parts == { + "M0 0H10", + "M0 0V10", + "M10 0V10", + "M0 20H10", + "M0 10V20", + "M10 10H20", + "M10 20H20", + "M20 10H30", + "M20 20H30", + "M30 10V20", + } # 칸 사이(10 · 20 세로) 겹선 없음