diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts index 8e7839a35..aef35f7bd 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Geom.ts @@ -16,6 +16,8 @@ export type LineKind = CrossPickPiece["line"]; export interface SplitPiece { line: LineKind; points: Pt[]; + /** 구조물 선 하나(295-3) — 나눈 조각이 같은 번호면 고를 때 한 묶음(닫힌 영역 · 저장은 조각대로). */ + group?: number; } /** 서버 `LINE_TOLERANCE_M` 과 같은 값 — 두 끝이 선에서 이만큼 안이면 「선 위」 · 고리 끝 맞음. */ @@ -221,7 +223,12 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] { 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 }); + if (pathLength(points) >= MIN_PIECE_M) + out.push({ + line: line.line, + points, + ...(line.group !== undefined && { group: line.group }), + }); } }); return out; diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index fcd7889fe..16b25c7a7 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -36,6 +36,8 @@ import { const SVG_NS = "http://www.w3.org/2000/svg"; export const PIECE_HIT_CLASS = "b06-detail__piece"; +/** 구조물 선 묶음 걸침 표시(295-3) — 조각 판정 선 위 · 누름은 거리 판정이라 걸침만. */ +export const PIECE_GROUP_CLASS = "b06-detail__piece-group"; /** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */ export const FACE_HIT_CLASS = "b06-detail__face"; @@ -100,12 +102,14 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli else if (el.classList.contains("b06-chart__rock-boundary")) others.push({ line: "rock", points }); } - const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({ + // 구조물 선(295-3) = 다른 선을 나누는 기준은 그대로 · 자기 조각은 한 묶음(고를 때 통째) + const straight = mergeStraightPieces(runs).map((piece, index): SplitPiece => ({ line: piece.structure ? "structure" : "design", points: [ [piece.from.offset, piece.from.elevation], [piece.to.offset, piece.to.elevation], ], + ...(piece.structure && { group: index }), })); return splitAtCrossings([...straight, ...others]); } @@ -117,6 +121,24 @@ export function toggleSelection(selected: number[], index: number, additive: boo return selected.length === 1 && selected[0] === index ? [] : [index]; } +/** 묶음 고름 바꿈(295-3) — 구조물 선 조각 묶음을 한 몸으로 넣고 빼기 · 하나면 `toggleSelection` 과 같음. */ +export function toggleMembers(selected: number[], members: number[], additive: boolean): number[] { + const all = members.every((m) => selected.includes(m)); + if (additive) + return all + ? selected.filter((i) => !members.includes(i)) + : [...selected, ...members.filter((m) => !selected.includes(m))]; + const same = all && selected.length === members.length; + return same ? [] : [...members]; +} + +/** 같은 묶음 조각 번호 — 묶음 없으면 그 하나. */ +export function groupMembers(pieces: SplitPiece[], index: number): number[] { + const group = pieces[index]?.group; + if (group === undefined) return [index]; + return pieces.flatMap((piece, i) => (piece.group === group ? [i] : [])); +} + const LINE_NAME: Record = { design: "설계선", structure: "구조물 선", @@ -128,6 +150,10 @@ const LINE_NAME: Record = { /** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPiece[]): string { if (!pieces.length) return ""; + // 구조물 선 한 묶음(295-3) = 선 하나 — 첫 조각 앞 끝 ~ 끝 조각 뒤 끝 + const group = pieces[0].group; + if (pieces.length > 1 && group !== undefined && pieces.every((p) => p.group === group)) + pieces = [{ ...pieces[0], points: [pieces[0].points[0], pieces.at(-1)!.points.at(-1)!] }]; if (pieces.length === 1) { const [piece] = pieces; const from = piece.points[0]; @@ -152,8 +178,9 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie } const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0); const area = ringArea(pieces.map((piece) => piece.points)); + const units = new Set(pieces.map((piece) => piece.group ?? piece)).size; return ( - `${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` + + `${units}개 · 길이 합 ${total.toFixed(2)}m\n` + (area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`) ); } @@ -382,7 +409,7 @@ export function attachDetailSelect(input: { // 선이 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬 event.stopImmediatePropagation(); chosenFaces = []; - selected = toggleSelection(selected, pick.piece, additive); + selected = toggleMembers(selected, groupMembers(pieces, pick.piece), additive); show(); input.picked(additive); }; @@ -416,6 +443,19 @@ export function attachDetailSelect(input: { layer?.append(hit); return hit; }); + // 구조물 선 묶음 걸침 표시(295-3) — 조각 대신 선 하나로 비침 + new Set(pieces.flatMap((piece) => (piece.group === undefined ? [] : [piece.group]))).forEach( + (group) => { + const members = pieces.flatMap((piece, i) => (piece.group === group ? [i] : [])); + const el = document.createElementNS(SVG_NS, "polyline"); + el.setAttribute( + "points", + [paths[members[0]][0], paths[members.at(-1)!].at(-1)!].join(" "), + ); + el.setAttribute("class", PIECE_GROUP_CLASS); + layer?.append(el); + }, + ); show(); }, setMode(kind) { diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index 3e3e0de14..27207c53b 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -196,7 +196,8 @@ } /* 선 조각 판정 선(209-1) — 투명 굵은 선 · 배율 무관 굵기 · 고른 조각 = 붉은 띠 */ -.b06-detail__piece { +.b06-detail__piece, +.b06-detail__piece-group { fill: none; stroke: transparent; /* 누름 판정은 거리(LINE_PICK_PX 4) — 굵기는 걸침 표시만 */ @@ -207,7 +208,9 @@ cursor: pointer; } -.b06-detail__piece:hover { +/* 구조물 선 묶음(295-3) — 나뉜 조각 위 한 줄 · 걸치면 선 하나로 비침 */ +.b06-detail__piece:hover, +.b06-detail__piece-group:hover { stroke: color-mix(in srgb, var(--color-accent) 25%, transparent); } diff --git a/resources/tester/helper_295_pick_order_detail.cjs b/resources/tester/helper_295_pick_order_detail.cjs new file mode 100644 index 000000000..f380f8e9f --- /dev/null +++ b/resources/tester/helper_295_pick_order_detail.cjs @@ -0,0 +1,273 @@ +/* PLAN 295 단위검증 헬퍼 — 횡단 카드 고르기 차례 · [상세] 모달 고르기 차례 · 첫 보기 · 구조물 선 안 나눔. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_295_pick_order_detail.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 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 P = loadTs(path.join(B06, "B06_Section_UI_Cross_Pick_Order.ts")); +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 H = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Home.ts")); +const out = {}; + +/* ── 카드: 흉내 층 ───────────────────────────────────────────────────── */ +function node(name, classes, points) { + return { + name, + parent: null, + tagName: points && classes.includes("b06-chart__design-hit") ? "polyline" : "polygon", + classList: { contains: (c) => classes.includes(c) }, + getAttribute: (a) => (a === "points" ? points : null), + }; +} +function layer(kids) { + const listeners = []; + const self = { + children: [], + append: (...nodes) => + nodes.forEach((n) => { + const at = self.children.indexOf(n); + if (at >= 0) self.children.splice(at, 1); + self.children.push(n); + }), + addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture }), + fire: (type, event) => { + let stopped = false; + const full = { ...event, stopPropagation: () => (stopped = true) }; + listeners.filter((l) => l.type === type && l.capture).forEach((l) => l.fn(full)); + return stopped; + }, + }; + self.append(...kids); + return self; +} +const box = (x0, y0, x1, y1) => `${x0},${y0} ${x1},${y0} ${x1},${y1} ${x0},${y1}`; +const HIT = "b06-chart__culvert-revet-hit"; +// 꽂힌 옛 차례 — 면적 띠(맨 아래) · 보라 선(구조물 판정면 앞) · 형상 · 도수로 띠 · 벽 · 관 +const kids = [ + node("band", ["b06-chart__area"], box(0, 0, 600, 300)), + node("slope", ["b06-chart__design-hit"], "400,100 500,200"), + node("top", ["b06-chart__design-hit"], "300,100 400,100"), + node("shape", ["b06-chart__shape-hit"], box(300, 100, 310, 110)), // 100 + node("chute", [HIT, "b06-chart__chute-hit"], box(390, 90, 510, 210)), // 14400 — 성토 사면을 덮음 + node("wall", [HIT], box(200, 100, 220, 300)), // 4000 + node("pipe", [HIT], box(250, 250, 255, 255)), // 25 +]; +const L = layer(kids); +P.stackPickTargets(L); +out.cardOrder = L.children.map((n) => n.name); +// 누른 자리에서 맨 위 판정(뒤에서부터) — 다각형 안 · 선은 5 px 안 +const inside = (p, pts) => { + let inn = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const [ax, ay] = pts[i]; + const [bx, by] = pts[j]; + if (ay > p[1] !== by > p[1] && p[0] < ((bx - ax) * (p[1] - ay)) / (by - ay) + ax) inn = !inn; + } + return inn; +}; +const gap = (p, [a, b]) => { + const d = [b[0] - a[0], b[1] - a[1]]; + const t = Math.max( + 0, + Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / (d[0] ** 2 + d[1] ** 2)), + ); + return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]); +}; +const ptsOf = (n) => + n + .getAttribute("points") + .split(" ") + .map((q) => q.split(",").map(Number)); +const topAt = (p, order) => + [...order] + .reverse() + .find((n) => + n.classList.contains("b06-chart__design-hit") ? gap(p, ptsOf(n)) <= 5 : inside(p, ptsOf(n)), + )?.name; +out.slopeClick = topAt([450, 150], L.children); // 띠 안 성토 사면 위 +out.slopeClickOld = topAt([450, 150], kids); // 옛 차례 +out.chuteClick = topAt([480, 120], L.children); // 띠 안 · 선 밖 +out.pipeOverWall = topAt([252, 252], [...L.children]); +out.area = P.pointsArea(box(0, 0, 10, 20)); +// 끌기 = 고름 아님 +L.fire("pointerdown", { clientX: 10, clientY: 10 }); +out.dragStopped = L.fire("click", { clientX: 20, clientY: 10 }); +L.fire("pointerdown", { clientX: 10, clientY: 10 }); +out.clickStopped = L.fire("click", { clientX: 11, clientY: 11 }); + +/* ── 모달: 고르기 차례 ───────────────────────────────────────────────── */ +const sq = (x0, y0, x1, y1) => [ + [x0, y0], + [x1, y0], + [x1, y1], + [x0, y1], +]; +const big = sq(0, 0, 100, 100); +const small = sq(40, 40, 60, 60); +const edges = (ring) => ring.map((p, i) => [p, ring[(i + 1) % ring.length]]); +const paths = [...edges(big), ...edges(small)]; +const pieces = paths.map((points) => ({ line: "design", points })); +const rings = [big, small]; +const areas = [10000, 400]; +const pick = (p, band = null) => S.pickAt(p, 1, paths, rings, areas, pieces, band); +out.modal = { + line: pick([50, 41]), // 작은 면 둘레 1 px + small: pick([50, 50]), + big: pick([20, 20]), + bandSmaller: pick([50, 50], 100), + bandBigger: pick([50, 50], 5000), + bandBigFace: pick([20, 20], 5000), + bandOnly: S.pickAt([500, 500], 1, paths, rings, areas, pieces, 50), + lineOverBand: pick([50, 41], 1), +}; + +/* ── 모달: 첫 보기 ───────────────────────────────────────────────────── */ +const plot = { x: 50, y: 10, width: 500, height: 280 }; +const center = [300, 150]; +const drawn = [ + [100, 120], + [500, 140], + [300, 360], // 아래로 210 — 큰 쪽 + [200, 100], + [5000, 9000], // 가로 창 밖 = 셈 안 함 +]; +const home = H.detailHomeView(drawn, center, plot, 1, 16); +const map = ([x, y]) => [home.tx + home.scale * x, home.ty + home.scale * y]; +out.home = { + scale: home.scale, + center: map(center), + plotCenter: [plot.x + plot.width / 2, plot.y + plot.height / 2], + fits: drawn.slice(0, 4).every(([x, y]) => { + const [sx, sy] = map([x, y]); + return ( + sx >= plot.x - 1e-6 && + sx <= plot.x + plot.width + 1e-6 && + sy >= plot.y - 1e-6 && + sy <= plot.y + plot.height + 1e-6 + ); + }), + flat: H.detailHomeView([[300, 150]], center, plot, 2, 16).scale, +}; + +/* ── 모달: 구조물 선 안 나눔 ─────────────────────────────────────────── */ +// 상자(구조물 네 변 · 변마다 묶음) + 가로지르는 원지반선 +const boxLines = edges(sq(0, 0, 10, 10)).map((points, group) => ({ + line: "structure", + points, + group, +})); +const split = G.splitAtCrossings([ + ...boxLines, + { + line: "ground", + points: [ + [-5, 5], + [15, 5], + ], + }, +]); +const left = split.findIndex( + (p) => p.line === "structure" && p.points.every(([x]) => Math.abs(x) < 1e-9), +); +const members = S.groupMembers(split, left); +const chosen = S.toggleMembers([], members, false); +out.structure = { + pieces: split.length, + groundPieces: split.filter((p) => p.line === "ground").length, + members: members.length, + chosen: chosen.length, + again: S.toggleMembers(chosen, members, false).length, + additiveOff: S.toggleMembers([...chosen, 99], members, true), + label: S.selectionLabel( + undefined, + chosen.map((i) => split[i]), + ), + faces: G.findFaces(split) + .map((f) => Math.round(f.area * 100) / 100) + .sort(), + // 옛 저장 조각(구조물 선 아래 반쪽) — 그대로 다시 고름 + oldPick: split.filter((p) => S.samePiece(p, { line: "structure", start: [0, 0], end: [0, 5] })) + .length, + wholeValue: G.piecesValue( + "length", + chosen.map((i) => G.pieceSource(split[i])), + {}, + ), +}; +// collectPieces — 구조물 선(설계선 표식 없음)에 묶음 · 설계선은 없음 +const axes = { + widthPx: 600, + heightPx: 300, + pixelsPerMeter: 10, + maxOffset: 20, + displayMax: 120, + elevationMid: 110, + exaggeration: 1, +}; +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 collected = S.collectPieces( + [ + el( + ["b06-chart__design-cross"], + [ + [-10, 110], + [10, 110], + ], + { "data-design-line": "" }, + ), + el( + ["b06-chart__design-cross"], + [ + [0, 105], + [0, 115], + ], + ), + ], + axes, +); +out.collected = collected.map((p) => ({ line: p.line, group: p.group ?? null })); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_295_pick_order_detail.py b/resources/tester/test_295_pick_order_detail.py new file mode 100644 index 000000000..f69390536 --- /dev/null +++ b/resources/tester/test_295_pick_order_detail.py @@ -0,0 +1,118 @@ +"""PLAN 295 — 횡단 카드 · [상세] 모달 고르기 차례 · 모달 첫 보기 · 구조물 선 안 나눔(294-5 포함). + +- 295-1 카드 = 판정면 넓이 큰 것부터 아래(작은 것 위) · 보라 선(사면 손잡이) 맨 위 · 띠 있는 성토 사면 누름 = 사면 · + 끌기 = 고름 아님 / 모달 `pickAt` = 선 > 작은 면 > 큰 면 · 띠(띠는 영역보다 작을 때만) +- 295-2 모달 첫 보기 = 중심점이 칸 가운데 · 그림 전체가 들어가게 축마다 큰 쪽 반폭 +- 295-3 모달 구조물 선 = 다른 선을 나누는 기준 그대로 · 고를 땐 한 선 통째 · 닫힌 영역 그대로 · 옛 조각 다시 고름 + +실제 TS 를 `helper_295_pick_order_detail.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +B06 = os.path.join(ROOT, "B06_Section") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(name: str) -> str: + with open(os.path.join(B06, name), encoding="utf-8") as f: + return f.read().replace(chr(13), "") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_295_pick_order_detail.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_카드_작은_것_위_보라_선_맨_위(out): + # 띠(14400) > 벽(4000) > 형상(100) > 관(25) · 보라 선 둘 맨 위 · 면적 띠는 맨 아래 그대로 + assert out["cardOrder"] == ["band", "chute", "wall", "shape", "pipe", "slope", "top"] + assert out["area"] == 200 + + +def test_카드_띠_있는_성토_사면_누름은_사면_294_5(out): + assert out["slopeClickOld"] == "chute" # 옛 차례 = 띠가 사면을 가림 + assert out["slopeClick"] == "slope" + assert out["chuteClick"] == "chute" # 선 밖 띠 자리는 띠 + assert out["pipeOverWall"] == "pipe" + + +def test_카드_끌기는_고름_아님(out): + assert out["dragStopped"] is True + assert out["clickStopped"] is False + + +def test_모달_선_작은_면_큰_면_띠(out): + m = out["modal"] + assert m["line"] == {"piece": 4} + assert m["small"] == {"face": 1} + assert m["big"] == {"face": 0} + assert m["bandBigger"] == {"face": 1} # 띠보다 좁은 영역이 앞(옛 「띠가 앞」 폐기) + assert m["bandSmaller"] == {"band": True} + assert m["bandBigFace"] == {"band": True} + assert m["bandOnly"] == {"band": True} + assert m["lineOverBand"] == {"piece": 4} + + +def test_모달_첫_보기_중심점_가운데(out): + h = out["home"] + assert h["center"] == pytest.approx(h["plotCenter"]) + assert h["scale"] == pytest.approx(140 / 210) # 아래로 먼 쪽 210 px 이 반높이에 + assert h["fits"] is True + assert h["flat"] == pytest.approx(2) # 세로가 낮으면 가로(카드 반폭) 배율 + + +def test_모달_구조물_선_한_조각(out): + s = out["structure"] + assert s["groundPieces"] == 3 # 구조물 선이 원지반선을 나눔(기준 그대로) + assert s["members"] == 2 and s["chosen"] == 2 # 나뉜 조각 둘 = 한 선 통째 + assert s["again"] == 0 + assert s["additiveOff"] == [99] + assert s["label"].startswith("구조물 선 길이 10.00m") + assert s["wholeValue"] == pytest.approx(10) + assert out["collected"] == [ + {"line": "design", "group": None}, + {"line": "design", "group": None}, + {"line": "structure", "group": 1}, + {"line": "structure", "group": 1}, + ] + + +def test_모달_닫힌_영역_옛_조각(out): + s = out["structure"] + assert s["faces"] == [50, 50] + assert s["oldPick"] == 1 + + +def test_배선(): + view = _read("B06_Section_UI_Cross_View.ts") + assert view.index("setLineActive(pickedLineAt(") < view.index("stackPickTargets(plotLayer)") + select = " ".join(_read("B06_Section_UI_Cross_Detail_Select.ts").split()) + assert "groupMembers(pieces, pick.piece)" in select + assert 'pick && "face" in pick' in select + table = _read("B06_Section_UI_Cross_Detail_Table.ts") + assert 'pointsArea(hit.getAttribute("points"))' in table + detail = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split()) + assert "detailHomeView(layerPoints(layer), [cx, cy], plot, homeScale, 16)" in detail + for name in ("B06_Section_UI_Cross_View.ts", "B06_Section_UI_Cross_Detail_Select.ts"): + assert len(_read(name).splitlines()) <= 700