From f6f0f74be54e0834062957603474fee01a36c807 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:00:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20=EB=B3=B4=EB=9D=BC=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=EC=84=A0=20=EA=B3=A0=EB=A6=84=20=E2=80=94=20=EC=A7=81?= =?UTF-8?q?=EC=84=A0=20=EC=A1=B0=EA=B0=81=20=C2=B7=20=EA=B0=80=EB=A6=BC=20?= =?UTF-8?q?=EB=BA=8C=20=C2=B7=20=EC=B9=98=EC=88=98=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=C2=B7=20=EA=B5=AC=EC=97=AD=20=ED=8C=90=EC=A0=95=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=ED=95=98=EB=82=98=EB=A7=8C(137-2=20=C2=B7?= =?UTF-8?q?=20137-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 B06_Section_UI_Cross_Design_Pick.ts — 조각 합침 · 벽 · 본체 밖 조각 · 투명 판정 선 · 치수 · crossZoneAt(139-6 용) · 페이지 한 칸 고름 - 폴리곤 자르기 = A00_Common/b_polygon_clip.ts 공용(B07 Cross_Overlay 에서 옮김 · 바깥 조각 갈래) - 카드 배선: 선 고르면 벽 · 본체 · 면적 풂 + 카드 고름 · 면적 고름도 본체 · 선 풂 - 시험 test_137_design_pick.py + helper_137_design_pick.cjs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- A00_Common/b_polygon_clip.ts | 65 ++++ .../B06_Section_UI_Cross_Design_Pick.ts | 313 +++++++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 63 +++- .../B06_Section_UI_Style_Cross_Areas.css | 15 + .../B06_Section_UI_Style_Cross_Controls.css | 17 +- .../B07_DesignDetail_Cross_Overlay.ts | 54 +-- resources/tester/helper_137_design_pick.cjs | 317 ++++++++++++++++++ resources/tester/test_137_design_pick.py | 110 ++++++ 8 files changed, 898 insertions(+), 56 deletions(-) create mode 100644 A00_Common/b_polygon_clip.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Design_Pick.ts create mode 100644 resources/tester/helper_137_design_pick.cjs create mode 100644 resources/tester/test_137_design_pick.py diff --git a/A00_Common/b_polygon_clip.ts b/A00_Common/b_polygon_clip.ts new file mode 100644 index 000000000..44d0f52bd --- /dev/null +++ b/A00_Common/b_polygon_clip.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * b_polygon_clip.ts + * 선분 · 점열을 폴리곤 경계에서 자르는 공용 조각 — B07 횡단 오버레이(안쪽 조각)와 + * B06 설계선 고름(벽 · 본체 밖 조각 · 137-2)이 같이 쓴다. 좌표계는 부르는 쪽 것 그대로. + * ========================================================================== */ + +export type XY = [number, number]; + +/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */ +export function pointInPolygon(point: XY, polygon: XY[]): boolean { + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { + const [xi, yi] = polygon[i]; + const [xj, yj] = polygon[j]; + const straddles = yi > point[1] !== yj > point[1]; + if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**(`keep = "outside"` 면 바깥 조각들)만 돌려준다. */ +export function clipSegment( + start: XY, + end: XY, + polygon: XY[], + keep: "inside" | "outside" = "inside", +): XY[][] { + const [x0, y0] = start; + const [x1, y1] = end; + const dx = x1 - x0; + const dy = y1 - y0; + const cuts = [0, 1]; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { + const [ex, ey] = polygon[j]; + const [fx, fy] = polygon[i]; + const denominator = dx * (fy - ey) - dy * (fx - ex); + if (Math.abs(denominator) < 1e-12) continue; + const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator; + const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator; + if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t); + } + cuts.sort((a, b) => a - b); + const runs: XY[][] = []; + for (let index = 0; index + 1 < cuts.length; index += 1) { + const from = cuts[index]; + const to = cuts[index + 1]; + if (to - from < 1e-9) continue; + const mid = (from + to) / 2; + if (pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon) !== (keep === "inside")) continue; + runs.push([ + [x0 + dx * from, y0 + dy * from], + [x0 + dx * to, y0 + dy * to], + ]); + } + return runs; +} + +/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */ +export function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] { + const runs: XY[][] = []; + for (let index = 0; index + 1 < points.length; index += 1) { + runs.push(...clipSegment(points[index], points[index + 1], polygon)); + } + return runs; +} diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts new file mode 100644 index 000000000..15305baca --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -0,0 +1,313 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Design_Pick.ts + * 횡단 카드 **보라 요소 고름**(설계선 · 구조물 트림 선 · 137-2). + * + * 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을 + * 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정 + * 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 적는다. + * 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다. + * 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다. + * ========================================================================== */ + +import type { CrossDesign } from "./B06_Section_Api_Fetch"; +import { clipSegment, type XY } from "../A00_Common/b_polygon_clip"; +import { sideRole } from "@util/common_util_cross_design_geometry"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const DESIGN_CLASS = "b06-chart__design-cross"; +const HIT_CLASS = "b06-chart__design-hit"; +/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */ +export const DESIGN_LINE_ATTR = "data-design-line"; +/** 보라 선을 덮는 벽 · 본체 폴리곤. */ +const OCCLUDER_CLASSES = [ + "b06-chart__culvert-revet", + "b06-chart__culvert-basin", + "b06-chart__ford-slab", + "b06-chart__box-barrel", +]; +/** 같은 직선으로 보는 꺾임 허용(m) — 설계선 표고는 0.1 mm 반올림이라 넉넉하다. */ +const STRAIGHT_TOL_M = 0.005; +/** 가리고 남은 조각이 이보다 짧으면 버린다(m) — 누를 수도 없다. */ +const MIN_PIECE_M = 0.02; + +export interface CrossPoint { + offset: number; + elevation: number; +} +/** 그려진 보라 선 한 줄(m 좌표). `structure` = 구조물 트림 선(설계선 오버레이 밖). */ +export interface DesignRun { + points: CrossPoint[]; + structure: boolean; +} +/** 직선 조각 — 두 끝(m) · 구조물 트림 선인가. */ +export interface DesignPiece { + from: CrossPoint; + to: CrossPoint; + structure: boolean; +} + +export type CrossZone = "road" | "cut" | "fill"; +export type CrossSide = "left" | "right"; +type ZoneDesign = Pick< + CrossDesign, + "road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode" +>; + +/** + * offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + + * 구조물 트림 선. 사면 역할은 단면유형(`sideRole`)으로 · 쪽 = +offset 좌. + */ +export function crossZoneAt( + design: ZoneDesign, + offsetM: number, + structure = false, +): { zone: CrossZone; side: CrossSide } { + const side: CrossSide = offsetM >= 0 ? "left" : "right"; + const edges = design.road_edges; + const hi = edges ? Math.max(edges.left.offset_m, edges.right.offset_m) : 0; + const lo = edges ? Math.min(edges.left.offset_m, edges.right.offset_m) : 0; + if (structure) return { zone: "fill", side }; + if (offsetM >= lo - 1e-6 && offsetM <= hi + 1e-6) return { zone: "road", side }; + const ditch = design.ditch; + const ditchWidth = + design.ditch_enabled === false || !ditch || ditch.type === "none" + ? 0 + : ditch.type === "standard" + ? ditch.top_width_m + : ditch.width_m; + if (design.ditch_side === "left" && offsetM > hi && offsetM <= hi + ditchWidth + 1e-6) + return { zone: "cut", side }; + if (design.ditch_side === "right" && offsetM < lo && offsetM >= lo - ditchWidth - 1e-6) + return { zone: "cut", side }; + let roles: [string, string] = ["fill", "fill"]; + try { + roles = sideRole(design.section_mode); + } catch { + // 모르는 단면유형 = 성토로 봄 + } + return { zone: (side === "left" ? roles[0] : roles[1]) === "cut" ? "cut" : "fill", side }; +} + +/** 조각 구역 = 가운데 자리의 구역. */ +export const pieceZone = (design: ZoneDesign, piece: DesignPiece) => + crossZoneAt(design, (piece.from.offset + piece.to.offset) / 2, piece.structure); + +const gap = (a: CrossPoint, b: CrossPoint): number => + Math.hypot(a.offset - b.offset, a.elevation - b.elevation); + +/** 점열이 한 직선 위에서 한 방향으로만 가는가(되접힘 없음). */ +function isStraight(points: CrossPoint[]): boolean { + const a = points[0]; + const b = points[points.length - 1]; + const length = gap(a, b); + if (length < 1e-9) return false; + const ux = (b.offset - a.offset) / length; + const uy = (b.elevation - a.elevation) / length; + let along = -STRAIGHT_TOL_M; + for (const p of points) { + const dx = p.offset - a.offset; + const dy = p.elevation - a.elevation; + if (Math.abs(ux * dy - uy * dx) > STRAIGHT_TOL_M) return false; + const t = ux * dx + uy * dy; + if (t < along - STRAIGHT_TOL_M) return false; + along = Math.max(along, t); + } + return true; +} + +/** 그려진 선들 → 같은 방향 토막을 합친 직선 조각(설계선 · 구조물 선은 따로 합침). */ +export function mergeStraightPieces(runs: DesignRun[]): DesignPiece[] { + const chains: Array<{ points: CrossPoint[]; structure: boolean }> = []; + for (const run of runs) { + for (let i = 1; i < run.points.length; i += 1) { + const start = run.points[i - 1]; + const end = run.points[i]; + if (gap(start, end) < 1e-6) continue; + const last = chains[chains.length - 1]; + if ( + last && + last.structure === run.structure && + gap(last.points[last.points.length - 1], start) < 1e-3 && + isStraight([...last.points, end]) + ) + last.points.push(end); + else chains.push({ points: [start, end], structure: run.structure }); + } + } + // 차례가 떨어진 토막도 끝이 닿고 한 직선이면 합친다(방향이 거꾸로 그려진 선 포함). + const joined = (p: CrossPoint[], q: CrossPoint[]): CrossPoint[] | null => { + for (const a of [p, [...p].reverse()]) + for (const b of [q, [...q].reverse()]) + if (gap(a[a.length - 1], b[0]) < 1e-3 && isStraight([...a, ...b.slice(1)])) + return [...a, ...b.slice(1)]; + return null; + }; + let merged = true; + while (merged) { + merged = false; + for (let i = 0; i < chains.length && !merged; i += 1) + for (let j = i + 1; j < chains.length && !merged; j += 1) { + if (chains[i].structure !== chains[j].structure) continue; + const points = joined(chains[i].points, chains[j].points); + if (!points) continue; + chains[i] = { points, structure: chains[i].structure }; + chains.splice(j, 1); + merged = true; + } + } + return chains.map((chain) => ({ + from: chain.points[0], + to: chain.points[chain.points.length - 1], + structure: chain.structure, + })); +} + +/** 벽 · 본체 폴리곤(m)에 가린 부분을 뺀 **보이는 조각**만. */ +export function visiblePieces(pieces: DesignPiece[], occluders: CrossPoint[][]): DesignPiece[] { + const polygons = occluders + .filter((polygon) => polygon.length >= 3) + .map((polygon) => polygon.map((p) => [p.offset, p.elevation] as XY)); + const out: DesignPiece[] = []; + for (const piece of pieces) { + let runs: XY[][] = [ + [ + [piece.from.offset, piece.from.elevation], + [piece.to.offset, piece.to.elevation], + ], + ]; + for (const polygon of polygons) + runs = runs.flatMap(([a, b]) => clipSegment(a, b, polygon, "outside")); + for (const [a, b] of runs) { + const from = { offset: a[0], elevation: a[1] }; + const to = { offset: b[0], elevation: b[1] }; + if (gap(from, to) >= MIN_PIECE_M) out.push({ from, to, structure: piece.structure }); + } + } + return out; +} + +/** 치수 — 사면길이 · 수평 · 높이(m) · 구배(사면 1:n · 노면 %). */ +export function pieceMeasure( + piece: DesignPiece, + zone: CrossZone, +): { lengthM: number; horizontalM: number; heightM: number; grade: string } { + const horizontalM = Math.abs(piece.to.offset - piece.from.offset); + const heightM = Math.abs(piece.to.elevation - piece.from.elevation); + const grade = + zone === "road" + ? horizontalM < 1e-6 + ? "—" + : `${((heightM / horizontalM) * 100).toFixed(1)}%` + : heightM < 1e-4 + ? "0.0%" + : `1:${(horizontalM / heightM).toFixed(2)}`; + return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade }; +} + +export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string { + const { zone } = pieceZone(design, piece); + const m = pieceMeasure(piece, zone); + return ( + `${zone === "road" ? "길이" : "사면길이"} ${m.lengthM.toFixed(2)}m · ` + + `수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m · ${m.grade}` + ); +} + +/* ── 페이지 한 칸 고름 — 측점 + 조각 번호(카드를 다시 그려도 되살림 · 137-3) ───────── */ +let picked: { stationId: string; index: number } | null = null; +export const pickedLineAt = (stationId: string): number | null => + picked?.stationId === stationId ? picked.index : null; +export function setPickedLine(stationId: string, index: number | null): void { + if (index !== null) picked = { stationId, index }; + else if (picked?.stationId === stationId) picked = null; +} + +/* ── 카드 배선 ─────────────────────────────────────────────────────────── */ +const pointsOf = (element: Element): XY[] => { + if (element.tagName.toLowerCase() === "line") { + const n = (name: string) => Number(element.getAttribute(name)); + return [ + [n("x1"), n("y1")], + [n("x2"), n("y2")], + ]; + } + return (element.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number) as XY) + .filter((p) => p.length === 2 && p.every(Number.isFinite)); +}; + +export interface DesignLinePick { + pieces: DesignPiece[]; + /** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */ + label: HTMLElement; + setActive: (index: number | null) => void; +} + +/** + * 그려진 보라 선에 고름을 단다 — 벽 · 관 등을 다 그린 **뒤**(가림 · 판정면 차례) 부른다. + * `x` · `toDisplayY` = 카드 좌표 함수(1차식 — 역산해 m 로 되돌림). + */ +export function attachDesignLinePick(input: { + layer: SVGElement; + design: ZoneDesign; + x: (offset: number) => number; + toDisplayY: (elevation: number) => number; + onPick: (index: number) => void; +}): DesignLinePick { + const { layer, design, x, toDisplayY } = input; + const x0 = x(0); + const kx = x(1) - x0; + const y0 = toDisplayY(0); + const ky = toDisplayY(1) - y0; + const toM = ([px, py]: XY): CrossPoint => ({ offset: (px - x0) / kx, elevation: (py - y0) / ky }); + const kids = [...layer.children]; + const runs: DesignRun[] = kids + .filter((el) => el.classList.contains(DESIGN_CLASS)) + .map((el) => ({ + points: pointsOf(el).map(toM), + structure: !el.hasAttribute(DESIGN_LINE_ATTR), + })); + const occluders = kids + .filter( + (el) => + el.tagName.toLowerCase() === "polygon" && + OCCLUDER_CLASSES.some((name) => el.classList.contains(name)), + ) + .map((el) => pointsOf(el).map(toM)); + const pieces = visiblePieces(mergeStraightPieces(runs), occluders); + + // 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김. + const firstStructureHit = kids.find((el) => + el.classList.contains("b06-chart__culvert-revet-hit"), + ); + const hits = pieces.map((piece, index) => { + const hit = document.createElementNS(SVG_NS, "polyline"); + hit.setAttribute( + "points", + [piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), + ); + hit.setAttribute("class", HIT_CLASS); + hit.addEventListener("click", (event) => { + event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음 + input.onPick(index); + }); + layer.insertBefore(hit, firstStructureHit ?? null); + return hit; + }); + + const label = document.createElement("span"); + label.className = "b06-cross-card__line-dims"; + label.hidden = true; + return { + pieces, + label, + setActive: (index) => { + const piece = index === null ? undefined : pieces[index]; + hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index)); + label.hidden = !piece; + label.textContent = piece ? pieceLabel(design, piece) : ""; + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 1015dbb3f..19222df41 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -55,6 +55,13 @@ import type { } from "./B06_Section_UI_Cross_Culvert_Wire"; import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels"; +import { + attachDesignLinePick, + DESIGN_LINE_ATTR, + type DesignLinePick, + pickedLineAt, + setPickedLine, +} from "./B06_Section_UI_Cross_Design_Pick"; import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; @@ -191,6 +198,12 @@ export function createCrossSectionCard( let showRevetControl: (visible: boolean) => void = () => undefined; /** 조정창이 읽는 마지막 계산 결과(벽 제원 · 단 수 등) — `_Cross_View_Panels`. */ const culvertState = emptyCardCulvertState(); + /** 보라 선 고름(137-2) — 고름은 페이지 한 칸(`_Cross_Design_Pick`) · 강조는 그린 뒤 붙음. */ + let setLineActive: (index: number | null) => void = () => undefined; + const dropLine = (): void => { + setPickedLine(section.station_id, null); + setLineActive(null); + }; /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 @@ -204,6 +217,7 @@ export function createCrossSectionCard( box, isCardSelected: () => isSelected, clearArea: () => { + dropLine(); // 고름 하나만(137-3) if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1) if (activeArea === null) return; activeArea = null; @@ -227,6 +241,7 @@ export function createCrossSectionCard( setChipActive(null); } if (activeRevet !== null) { + dropLine(); // 고름 하나만(137-3) bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1) // 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이 // 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21 @@ -251,7 +266,10 @@ export function createCrossSectionCard( if (owns) revetOffset?.select(section.chainage_m, null); else card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? null); } - if (activeArea !== null || !nextSelected) bodies.clearSelection(); + if (activeArea !== null || !nextSelected) { + bodies.clearSelection(); + dropLine(); + } }; card.applyRevetHighlight = (key) => { highlightRevet = key; @@ -276,10 +294,34 @@ export function createCrossSectionCard( showRevetControl(false); revetOffset?.select(section.chainage_m, null); } + if (activeArea !== null) { + bodies.clearSelection(); // 고름 하나만 — 면적 강조도 본체 · 선과 같은 칸(137-3) + dropLine(); + } setBandActive(activeArea); setChipActive(activeArea); onAreaSelect?.(section.station_id, activeArea); }; + /** 보라 선 고름 — 다른 고름(벽 · 본체 · 면적)을 풀고 카드도 고름(137-3). 다시 누르면 풂. */ + const pickLine = (index: number): void => { + const next = pickedLineAt(section.station_id) === index ? null : index; + setPickedLine(section.station_id, next); + if (next !== null) { + if (activeRevet !== null) toggleRevet(activeRevet); + bodies.clearSelection(); + if (activeArea !== null) { + activeArea = null; + setBandActive(null); + setChipActive(null); + onAreaSelect?.(section.station_id, null); + } + if (!isSelected) onSelect(section.station_id); + } + setLineActive(next); + }; + // 고른 카드 · 다른 고름이 없을 때만 되살림 — 바깥 길(목록 · 3D)이 구조물을 골랐으면 선을 풂. + if (!selected || activeRevet !== null || activeArea !== null) + setPickedLine(section.station_id, null); appendCardHeader(card, section, stationInterval, onDesignChange); @@ -361,6 +403,7 @@ export function createCrossSectionCard( const toDisplayY = (elevation: number): number => y(elevationMid + (elevation - elevationMid) * exaggeration); appendGroundProfile(plotLayer, drawSamples, x, toDisplayY); + let linePick: DesignLinePick | null = null; if (section.design) { // 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다. @@ -465,6 +508,10 @@ export function createCrossSectionCard( x: (CROSS_PAD.left + widthPx - CROSS_PAD.right) / 2, y: heightPx - CROSS_PAD.bottom - 4, }); + // 설계선 오버레이가 그린 보라 선 표시 — 고름 구역에서 구조물 트림 선과 가름(137-2). + [...plotLayer.children] + .slice(designFrom) + .forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, "")); // 물넘이 자리 = 노면 · 측구 설계선 걷음(66-2 · 66-3 — 옛 계획고 점선이 드러남) if (fordPaved && section.ford_pavement) hideFordPavedRoad( @@ -524,6 +571,16 @@ export function createCrossSectionCard( if (highlightRevet) setRevetActive(highlightRevet); } raiseWalls(plotLayer); // 흙막이 · 옹벽 안을 지나는 다른 선 가림(66-5) + // 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2). + linePick = attachDesignLinePick({ + layer: plotLayer, + design: section.design, + x, + toDisplayY, + onPick: pickLine, + }); + setLineActive = linePick.setActive; + setLineActive(pickedLineAt(section.station_id)); } // 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한). @@ -589,7 +646,9 @@ export function createCrossSectionCard( // 팬으로 끌어다 본다(PLAN 79-2 · 교차점 폭으로 넓히던 것 폐지). fit: () => stationWidth.request(section.chainage_m, crossHalfWidth ?? maxOffset), }; - chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root); + const zoomBar = buildZoomControls(zoomPan, widthActions); + if (linePick) zoomBar.prepend(linePick.label); // 치수 = [+ − ⤢] 왼쪽(137-2) + chartWrap.append(svg, readout.root, zoomBar, panel.root); for (const panel of bodies.panels) chartWrap.append(panel.root); if (activeArea) { setBandActive(activeArea); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 6f08716c6..d8b8af232 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -146,6 +146,21 @@ stroke-dasharray: 4 4; } +/* 보라 선 고름(137-2) — 투명 판정 선 · 고르면 굵은 반투명 띠. */ +.b06-chart__design-hit { + fill: none; + stroke: transparent; + stroke-width: 10; + stroke-linecap: round; + pointer-events: stroke; + cursor: pointer; +} + +.b06-chart__design-hit.is-active { + stroke: color-mix(in srgb, var(--color-royal-amethyst) 45%, transparent); + stroke-width: 7; +} + /* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */ .b06-chart__carriageway-tick { stroke: var(--color-royal-amethyst); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index c53b382ca..b0e894daf 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -223,6 +223,20 @@ background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } +/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 같은 줄. */ +.b06-cross-card__line-dims { + display: flex; + align-items: center; + padding: 0 var(--spacing-4); + color: var(--color-royal-amethyst); + font-size: 0.75rem; + white-space: nowrap; +} + +.b06-cross-card__line-dims[hidden] { + display: none; +} + .b06-cross-card__zoom-btn { width: 22px; height: 22px; @@ -236,7 +250,8 @@ cursor: pointer; } -.b06-cross-card__zoom-btn:first-child { +.b06-cross-card__zoom-btn:first-child, +.b06-cross-card__line-dims[hidden] + .b06-cross-card__zoom-btn { border-left: none; } diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index 7dc4f5ea0..6d3b1488a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -22,6 +22,7 @@ import { appendPavementOverlay, } from "../B06_Section/B06_Section_UI_Cross_Design"; import { appendRevetmentOverlay } from "../B06_Section/B06_Section_UI_Cross_Revetment"; +import { clipPointsToPolygon, clipSegment, pointInPolygon } from "../A00_Common/b_polygon_clip"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -220,59 +221,6 @@ function clipPolygonOf(element: SVGElement): XY[] | null { return points.length >= 3 ? points : null; } -/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */ -function pointInPolygon(point: XY, polygon: XY[]): boolean { - let inside = false; - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { - const [xi, yi] = polygon[i]; - const [xj, yj] = polygon[j]; - const straddles = yi > point[1] !== yj > point[1]; - if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside; - } - return inside; -} - -/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */ -function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] { - const [x0, y0] = start; - const [x1, y1] = end; - const dx = x1 - x0; - const dy = y1 - y0; - const cuts = [0, 1]; - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { - const [ex, ey] = polygon[j]; - const [fx, fy] = polygon[i]; - const denominator = dx * (fy - ey) - dy * (fx - ex); - if (Math.abs(denominator) < 1e-12) continue; - const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator; - const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator; - if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t); - } - cuts.sort((a, b) => a - b); - const runs: XY[][] = []; - for (let index = 0; index + 1 < cuts.length; index += 1) { - const from = cuts[index]; - const to = cuts[index + 1]; - if (to - from < 1e-9) continue; - const mid = (from + to) / 2; - if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue; - runs.push([ - [x0 + dx * from, y0 + dy * from], - [x0 + dx * to, y0 + dy * to], - ]); - } - return runs; -} - -/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */ -function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] { - const runs: XY[][] = []; - for (let index = 0; index + 1 < points.length; index += 1) { - runs.push(...clipSegment(points[index], points[index + 1], polygon)); - } - return runs; -} - function circlePoints(center: XY, radius: number): XY[] { const points: XY[] = []; for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) { diff --git a/resources/tester/helper_137_design_pick.cjs b/resources/tester/helper_137_design_pick.cjs new file mode 100644 index 000000000..1b67514d8 --- /dev/null +++ b/resources/tester/helper_137_design_pick.cjs @@ -0,0 +1,317 @@ +/* PLAN 137-2 · 137-3 단위검증 헬퍼 — B06 보라 선 고름(조각 · 가림 · 치수 · 구역 · 판정 선 · 한 칸 고름). + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 입력 = 손으로 짠 단면 + `fixtures/b07_cross_overlay_sections.json` 배관 측점 85.052. 짝 = `test_137_design_pick.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, + "@ui/ui_template_elements": { showToast: () => undefined }, +}; + +// ── 화면 그릇 흉내 — 오버레이 · 판정 선이 쓰는 만큼만 ───────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = String(tag); + this.children = []; + this.attrs = {}; + this.listeners = {}; + this.hidden = false; + this._text = ""; + this._classes = new Set(); + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + remove: (...names) => names.forEach((n) => self._classes.delete(n)), + toggle: (name, force) => { + const on = force === undefined ? !self._classes.has(name) : !!force; + on ? self._classes.add(name) : self._classes.delete(name); + return on; + }, + contains: (name) => self._classes.has(name), + }; + this.style = { setProperty: () => {} }; + } + set className(value) { + this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); + } + get className() { + return [...this._classes].join(" "); + } + set textContent(value) { + this._text = String(value); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + get childElementCount() { + return this.children.length; + } + get nextElementSibling() { + const kids = this.parent?.children ?? []; + return kids[kids.indexOf(this) + 1] ?? null; + } + _detach(kid) { + if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid); + kid.parent = this; + } + append(...kids) { + kids.forEach((k) => { + this._detach(k); + this.children.push(k); + }); + } + appendChild(kid) { + this.append(kid); + return kid; + } + insertBefore(kid, ref) { + this._detach(kid); + const at = ref ? this.children.indexOf(ref) : -1; + if (at < 0) this.children.push(kid); + else this.children.splice(at, 0, kid); + return kid; + } + remove() { + if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this); + } + matches(selector) { + if (selector === "text") return this.tagName === "text"; + return selector + .split(",") + .map((part) => part.trim()) + .some((part) => + part === "text" ? this.tagName === "text" : this._classes.has(part.slice(1)), + ); + } + setAttribute(name, value) { + this.attrs[name] = String(value); + if (name === "class") this.className = value; + } + getAttribute(name) { + return name === "class" ? this.className : (this.attrs[name] ?? null); + } + hasAttribute(name) { + return name in this.attrs; + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + click() { + let stopped = false; + const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} }; + (this.listeners.click ?? []).forEach((fn) => fn(event)); + return stopped; + } +} +globalThis.document = { + createElement: (tag) => new FakeNode(tag), + createElementNS: (_ns, tag) => new FakeNode(tag), +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick")); +const { pointInPolygon } = loadTs(path.join(ROOT, "A00_Common", "b_polygon_clip.ts")); +const out = {}; +const r3 = (v) => Math.round(v * 1000) / 1000; +const pt = (offset, elevation) => ({ offset, elevation }); + +// ① 조각 — 0.5 m 토막 노면(가운데 꺾임) · 측구 · 사면이 직선 조각 다섯으로 +const sampled = (a, b, step = 0.5) => { + const n = Math.max(1, Math.round(Math.abs(b.offset - a.offset) / step)); + return Array.from({ length: n + 1 }, (_, i) => + pt( + a.offset + ((b.offset - a.offset) * i) / n, + a.elevation + ((b.elevation - a.elevation) * i) / n, + ), + ); +}; +const line = [ + ...sampled(pt(-6, 98), pt(-2.5, 99.95)), // 우 성토 사면 + ...sampled(pt(-2.5, 99.95), pt(0, 100)).slice(1), // 우 노면 2% + ...sampled(pt(0, 100), pt(2.5, 99.95)).slice(1), // 좌 노면 2% + ...sampled(pt(2.5, 99.95), pt(3, 99.65)).slice(1), // 측구 안 + ...sampled(pt(3, 99.65), pt(6, 102.65)).slice(1), // 좌 절토 사면 +].map((p) => ({ offset: r3(p.offset), elevation: Math.round(p.elevation * 1e4) / 1e4 })); +// 토막마다 한 줄(설계선 오버레이 꼴) + 차례를 뒤집은 한 토막(거꾸로 그린 선) +const runs = []; +for (let i = 1; i < line.length; i += 1) + runs.push({ points: [line[i - 1], line[i]], structure: false }); +runs.push({ points: [pt(-6.5, 98 - (0.5 * 1.95) / 3.5), pt(-6, 98)].reverse(), structure: false }); +const merged = pick.mergeStraightPieces(runs); +out.merge = { + segments: runs.length, + pieces: merged.map((p) => [r3(p.from.offset), r3(p.to.offset)].sort((a, b) => a - b)), +}; +// 구조물 선은 같은 직선이어도 설계선과 안 합침 +out.mergeStructure = pick.mergeStraightPieces([ + { points: [pt(10, 0), pt(11, 1)], structure: false }, + { points: [pt(11, 1), pt(12, 2)], structure: true }, +]).length; + +// ② 가림 — 사면을 가로지르는 벽 → 벽 밖 두 조각 +const slope = [{ from: pt(3, 99.65), to: pt(6, 102.65), structure: false }]; +const wall = [pt(4, 100), pt(4.5, 100), pt(4.5, 103), pt(4, 103)]; +const visible = pick.visiblePieces(slope, [wall]); +out.occlude = { + pieces: visible.map((p) => [r3(p.from.offset), r3(p.to.offset)]), + // 통째로 덮이면 조각 없음 + covered: pick.visiblePieces(slope, [[pt(2, 90), pt(7, 90), pt(7, 110), pt(2, 110)]]).length, +}; + +// ③ 치수 +const fillPiece = { from: pt(-2.5, 99.95), to: pt(-5.5, 97.95), structure: false }; +const roadPiece = { from: pt(0, 100), to: pt(2.5, 99.95), structure: false }; +out.measure = { + fill: pick.pieceMeasure(fillPiece, "fill"), + road: pick.pieceMeasure(roadPiece, "road"), + flatSlope: pick.pieceMeasure({ from: pt(4, 1), to: pt(5, 1), structure: false }, "cut").grade, +}; +const design = { + road_edges: { + left: { offset_m: 2.5, elevation_m: 99.95 }, + right: { offset_m: -2.5, elevation_m: 99.95 }, + }, + ditch: { type: "standard", top_width_m: 0.5, bottom_width_m: 0.2, depth_m: 0.3 }, + ditch_side: "left", + ditch_enabled: true, + section_mode: "left_cut", +}; +out.label = { fill: pick.pieceLabel(design, fillPiece), road: pick.pieceLabel(design, roadPiece) }; + +// ④ 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + 구조물 선 +const zone = (o, structure = false, d = design) => pick.crossZoneAt(d, o, structure); +out.zone = { + road: zone(1), + roadEdge: zone(-2.5).zone, + ditch: zone(2.8), + cutSlope: zone(5), + fillSlope: zone(-4), + structure: zone(5, true).zone, + rightCutDitch: zone(-2.8, false, { ...design, ditch_side: "right", section_mode: "right_cut" }) + .zone, + bothFill: zone(5, false, { ...design, ditch_enabled: false, section_mode: "both_fill" }).zone, + pieceZones: merged.map((p) => pick.pieceZone(design, p).zone), +}; + +// ⑤ 실제 측점 — 설계선 + 배관 오버레이를 그린 뒤 고름을 붙임(카드와 같은 차례) +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts")); +const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert")); +const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design")); +const { raiseWalls } = loadTs(B06("B06_Section_UI_Cross_Wall")); +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), +); +const sections = fixture.cross_sections; +const pipeSection = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); +const layout = computeStoredLayouts(pipeSection, sections).culvert; +const x = (o) => 100 - o * 12; +const y = (e) => 900 - (e - 850) * 12; +const layer = new FakeNode("g"); +const from = layer.childElementCount; +appendCrossDesignOverlay(layer, pipeSection.design, x, y, pipeSection.samples, layout.designTrim); +layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, "")); +appendCulvertOverlay(layer, layout, x, y, () => undefined, false); +raiseWalls(layer); +const picks = []; +const handle = pick.attachDesignLinePick({ + layer, + design: pipeSection.design, + x, + toDisplayY: y, + onPick: (i) => picks.push(i), +}); +const kids = layer.children; +const hits = kids.filter((k) => k.classList.contains("b06-chart__design-hit")); +const firstStructureHit = kids.findIndex((k) => + k.classList.contains("b06-chart__culvert-revet-hit"), +); +const lastLineHit = kids.reduce( + (last, k, i) => (k.classList.contains("b06-chart__design-hit") ? i : last), + -1, +); +const walls = kids + .filter((k) => k.tagName === "polygon" && k.classList.contains("b06-chart__culvert-revet")) + .map((k) => + k + .getAttribute("points") + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)), + ); +const insideWall = handle.pieces.filter((p) => { + const mid = [x((p.from.offset + p.to.offset) / 2), y((p.from.elevation + p.to.elevation) / 2)]; + return walls.some((w) => pointInPolygon(mid, w)); +}).length; +const designLines = kids.filter((k) => k.classList.contains("b06-chart__design-cross")).length; +const stopped = hits[0].click(); +handle.setActive(1); +const activeOn = hits.map((h) => h.classList.contains("is-active")); +const labelOn = { hidden: handle.label.hidden, text: handle.label.textContent }; +handle.setActive(null); +out.real = { + designLines, + pieces: handle.pieces.length, + structurePieces: handle.pieces.filter((p) => p.structure).length, + hits: hits.length, + wallCount: walls.length, + insideWall, + hitsBelowStructure: firstStructureHit < 0 || lastLineHit < firstStructureHit, + picked: picks, + stopped, + activeCount: activeOn.filter(Boolean).length, + activeIndex: activeOn.indexOf(true), + labelOn, + labelOff: { hidden: handle.label.hidden, text: handle.label.textContent }, + zones: [...new Set(handle.pieces.map((p) => pick.pieceZone(pipeSection.design, p).zone))].sort(), +}; + +// ⑥ 페이지 한 칸 — 다른 측점을 고르면 앞 측점 고름은 사라짐 · 남의 측점 풀기는 안 건드림 +pick.setPickedLine("A", 2); +const a1 = pick.pickedLineAt("A"); +pick.setPickedLine("B", 1); +const a2 = pick.pickedLineAt("A"); +pick.setPickedLine("A", null); +out.slot = { a1, a2, b: pick.pickedLineAt("B") }; +pick.setPickedLine("B", null); +out.slot.cleared = pick.pickedLineAt("B"); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_137_design_pick.py b/resources/tester/test_137_design_pick.py new file mode 100644 index 000000000..c75c230bf --- /dev/null +++ b/resources/tester/test_137_design_pick.py @@ -0,0 +1,110 @@ +"""PLAN 137-2 · 137-3 — B06 보라 선 고름: 직선 조각 · 가림 · 치수 · 구역 · 판정 선 · 페이지 한 칸. + +짝: `B06_Section/B06_Section_UI_Cross_Design_Pick.ts` · `A00_Common/b_polygon_clip.ts`(B07 에서 옮김) · +`B06_Section_UI_Cross_View.ts`(배선 · 하나만). 실제 TS 를 `helper_137_design_pick.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_137_design_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _src(rel: str) -> str: + with open(os.path.join(ROOT, rel), encoding="utf-8") as f: + return f.read() + + +def test_같은_방향_토막은_직선_조각_하나(out) -> None: + """0.5 m 토막 25개 → 성토 사면 · 노면 둘(가운데 꺾임) · 측구 · 절토 사면 = 다섯 · 거꾸로 그린 토막도 합침.""" + assert out["merge"]["segments"] == 25 + assert out["merge"]["pieces"] == [[-6.5, -2.5], [-2.5, 0], [0, 2.5], [2.5, 3], [3, 6]] + assert out["mergeStructure"] == 2 # 구조물 선은 설계선과 안 합침 + + +def test_벽에_가린_부분은_빼고_보이는_조각만(out) -> None: + assert out["occlude"]["pieces"] == [[3, 4], [4.5, 6]] + assert out["occlude"]["covered"] == 0 + + +def test_치수_사면길이_수평_높이_구배(out) -> None: + fill = out["measure"]["fill"] + assert (fill["horizontalM"], fill["heightM"], fill["grade"]) == (3, 2, "1:1.50") + assert abs(fill["lengthM"] - 13**0.5) < 1e-9 + assert out["measure"]["road"]["grade"] == "2.0%" # 노면 = % + assert out["measure"]["flatSlope"] == "0.0%" + assert out["label"]["fill"] == "사면길이 3.61m · 수평 3.00m · 높이 2.00m · 1:1.50" + assert out["label"]["road"] == "길이 2.50m · 수평 2.50m · 높이 0.05m · 2.0%" + + +def test_구역_도로_절토_성토(out) -> None: + zone = out["zone"] + assert zone["road"] == {"zone": "road", "side": "left"} + assert zone["roadEdge"] == "road" + assert zone["ditch"] == {"zone": "cut", "side": "left"} # 측구 = 절토 + assert zone["cutSlope"] == {"zone": "cut", "side": "left"} + assert zone["fillSlope"] == {"zone": "fill", "side": "right"} + assert zone["structure"] == "fill" # 구조물 트림 선 = 성토 + assert zone["rightCutDitch"] == "cut" + assert zone["bothFill"] == "fill" + assert zone["pieceZones"] == ["fill", "road", "road", "cut", "cut"] + + +def test_실제_배관_측점_조각과_판정_선(out) -> None: + real = out["real"] + assert real["pieces"] < real["designLines"] # 토막이 조각으로 합쳐짐 + assert real["structurePieces"] > 0 # 구조물 트림 선도 고름 대상 + assert real["hits"] == real["pieces"] + assert real["wallCount"] == 2 and real["insideWall"] == 0 # 벽 안 조각 없음 + assert real["hitsBelowStructure"] is True # 구조물 판정면이 위 + assert real["picked"] == [0] and real["stopped"] is True # 카드 고름으로 안 번짐 + assert real["activeCount"] == 1 and real["activeIndex"] == 1 + assert real["labelOn"]["hidden"] is False and real["labelOn"]["text"].startswith("사면길이 ") + assert real["labelOff"] == {"hidden": True, "text": ""} + assert set(real["zones"]) <= {"road", "cut", "fill"} + + +def test_페이지_한_칸_고름(out) -> None: + assert out["slot"] == {"a1": 2, "a2": None, "b": 1, "cleared": None} + + +def test_카드_배선_하나만() -> None: + """137-3 — 선을 고르면 벽 · 본체 · 면적을 풀고 카드도 고름 · 다른 고름은 선을 풂.""" + view = _src("B06_Section/B06_Section_UI_Cross_View.ts") + assert "const pickLine = (index: number): void => {" in view + assert "if (!isSelected) onSelect(section.station_id);" in view + assert view.count("dropLine();") >= 4 # 본체 · 벽 · 카드 풂 · 면적 + assert "setLineActive(pickedLineAt(section.station_id));" in view # 다시 그려도 되살림 + assert "zoomBar.prepend(linePick.label);" in view # 치수 = [+ − ⤢] 왼쪽 + css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css") + assert ".b06-chart__design-hit.is-active {" in css + + +def test_폴리곤_자르기는_공용_한_벌() -> None: + b07 = _src("B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts") + assert "function clipSegment(" not in b07 and "function pointInPolygon(" not in b07 + assert 'from "../A00_Common/b_polygon_clip"' in b07