From 1ca7e61b084b61001124e8183c9474daa57193c7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 22:01:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(b06):=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=3D=20=EC=A2=81=EC=9D=80=20=EA=B2=83=20?= =?UTF-8?q?=EC=9C=84=20=E2=80=94=20=EC=B9=B4=EB=93=9C=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EB=A9=B4=20=EB=84=93=EC=9D=B4=20=ED=81=B0=20=EA=B2=83=EB=B6=80?= =?UTF-8?q?=ED=84=B0=20=EC=95=84=EB=9E=98=20=C2=B7=20=EB=B3=B4=EB=9D=BC=20?= =?UTF-8?q?=EC=84=A0=20=EB=A7=A8=20=EC=9C=84=20=C2=B7=20=EB=81=8C=EA=B8=B0?= =?UTF-8?q?=20=3D=20=EA=B3=A0=EB=A6=84=20=EC=95=84=EB=8B=98=20=C2=B7=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=84=A0=20>=20=EC=9E=91=EC=9D=80=20?= =?UTF-8?q?=EB=A9=B4=20>=20=ED=81=B0=20=EB=A9=B4=20=C2=B7=20=EB=9D=A0=20?= =?UTF-8?q?=C2=B7=20=EB=8F=84=EC=88=98=EB=A1=9C=20=EB=9D=A0=20=EC=9C=84=20?= =?UTF-8?q?=EC=84=B1=ED=86=A0=20=EC=82=AC=EB=A9=B4=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?(295-1=20=C2=B7=20294-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B06_Section_UI_Cross_Design_Pick.ts | 2 +- .../B06_Section_UI_Cross_Detail_Select.ts | 27 ++++++--- .../B06_Section_UI_Cross_Detail_Table.ts | 8 ++- .../B06_Section_UI_Cross_Pick_Order.ts | 58 +++++++++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 2 + 5 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Pick_Order.ts diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 2d8958dd7..308d96a8c 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -419,7 +419,7 @@ export function attachDesignLinePick(input: { const pieces = visiblePieces(mergeStraightPieces(runs), occluders); if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces)); - // 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김. + // 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1). const firstStructureHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"), ); diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index f6f8f101e..fcd7889fe 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -236,6 +236,8 @@ const inside = (p: Pt, ring: Pt[]): boolean => * 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이. * 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다 * 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역. + * 고르기 차례(295-1 · 카드와 같은 잣대) = 선 > 작은 면 > 큰 면 · 띠 — `bandArea`(그 자리 면적 띠 + * 넓이 · 그림 px²)가 영역보다 작을 때만 띠(표가 맡음). */ export function pickAt( point: Pt, @@ -244,7 +246,8 @@ export function pickAt( rings: Pt[][], areas: number[], pieces: SplitPiece[], -): { piece: number } | { face: number } | null { + bandArea: number | null = null, +): { piece: number } | { face: number } | { band: true } | null { const gaps = paths.map((path) => pathGap(point, path) * scale); const nearest = gaps.reduce((best, gap, i) => (gap < gaps[best] ? i : best), 0); const ends = (piece: SplitPiece): Pt[] => [ @@ -268,9 +271,17 @@ export function pickAt( (face === undefined || gaps[nearest] * 2 <= across) ) return { piece: nearest }; + const faceArea = face === undefined ? Infinity : Math.abs(shoelace(rings[face])); + if (bandArea !== null && bandArea < faceArea) return { band: true }; return face === undefined ? null : { face }; } +const shoelace = (ring: Pt[]): number => + ring.reduce((sum, [x0, y0], i) => { + const [x1, y1] = ring[(i + 1) % ring.length]; + return sum + (x0 * y1 - x1 * y0) / 2; + }, 0); + /** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */ const pieceKey = (piece: SplitPiece | undefined): string => piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : ""; @@ -283,8 +294,8 @@ export function attachDetailSelect(input: { picked: (additive: boolean) => void; /** [칸에 넣기] 단추. */ commit: () => void; - /** 면적 고르기 중 그 자리에 면적 띠가 있나 — 있으면 영역 대신 띠(표가 맡음). */ - band?: (event: MouseEvent) => boolean; + /** 면적 고르기 중 그 자리 면적 띠 넓이(그림 px²) · 없음 = null — 영역보다 작을 때만 띠(295-1). */ + band?: (event: MouseEvent) => number | null; }): DetailSelect { const { cad } = input; let pieces: SplitPiece[] = []; @@ -343,7 +354,8 @@ export function attachDetailSelect(input: { if (!ctm || !layer?.ownerSVGElement?.contains(event.target as Node)) return null; const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(ctm.inverse()); const areas = faces.map((face) => face.area); - return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces); + const band = mode === "area" ? (input.band?.(event) ?? null) : null; + return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces, band); }; const onClick = (event: MouseEvent): void => { @@ -352,9 +364,10 @@ export function attachDetailSelect(input: { const additive = shift || mode === "area"; noteText = null; if (!pick || !("piece" in pick)) { - if (pick && !(mode === "area" && input.band?.(event))) { - // 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) - event.stopPropagation(); + if (pick && "face" in pick) { + // 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) · + // 띠보다 좁은 영역이라 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬(295-1) + event.stopImmediatePropagation(); const index = pick.face; chosenFaces = toggleSelection(chosenFaces, index, shift); selected = regionEdges(chosenFaces.map((i) => faces[i])); diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts index 366264f28..690908d7c 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -25,6 +25,7 @@ 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 } from "./B06_Section_UI_Cross_Detail_Select"; +import { pointsArea } from "./B06_Section_UI_Cross_Pick_Order"; /** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ export const MAX_ROWS = 12; @@ -387,13 +388,16 @@ export function attachDetailTable(host: DetailTableHost): { if (picking?.kind === "length" && !additive) commitSelection(); }, commit: commitSelection, - band: (event) => !!bandAt(event), + band: (event) => { + const hit = bandAt(event); + return hit ? pointsArea(hit.getAttribute("points")) : null; + }, }); const bandAt = (event: MouseEvent): Element | undefined => document .elementsFromPoint(event.clientX, event.clientY) .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); - // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 누름은 위가 막음(261-3 거리 판정) + // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4) — 선 · 띠보다 좁은 영역은 위가 막음(261-3 거리 판정 · 295-1) const onPick = (event: MouseEvent): void => { if (picking?.kind !== "area") return; const hit = bandAt(event); diff --git a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts new file mode 100644 index 000000000..e3fd89830 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts @@ -0,0 +1,58 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Pick_Order.ts + * 횡단 카드 고르기 차례(295-1) — 고를 자리가 좁은 것이 위(모달 `pickAt` 과 같은 잣대). + * + * 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) · + * 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로. + * 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님. + * ========================================================================== */ + +/** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */ +const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"]; +/** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */ +const LINE_HIT_CLASS = "b06-chart__design-hit"; +/** 끌기로 볼 움직임(px) — 형상 고름(`SHAPE_DRAG_PX`)과 같은 값. */ +const DRAG_PX = 3; + +/** `points` 속성 다각형 넓이(px²) — 점이 셋 못 되면 0. */ +export function pointsArea(points: string | null): number { + const pts = (points ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)) + .filter((p) => p.length === 2 && p.every(Number.isFinite)); + if (pts.length < 3) return 0; + let twice = 0; + pts.forEach(([x0, y0], i) => { + const [x1, y1] = pts[(i + 1) % pts.length]; + twice += x0 * y1 - x1 * y0; + }); + return Math.abs(twice) / 2; +} + +/** 판정면을 넓이 차례로 다시 세우고 보라 선 판정을 맨 위로 · 끌기 누름을 막음. */ +export function stackPickTargets(layer: SVGElement): void { + const kids = [...layer.children]; + const surfaces = kids + .filter((el) => SURFACE_HIT_CLASSES.some((name) => el.classList.contains(name))) + .map((el) => ({ el, area: pointsArea(el.getAttribute("points")) })) + .sort((a, b) => b.area - a.area) + .map(({ el }) => el); + const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS)); + layer.append(...surfaces, ...lines); + let down: { x: number; y: number } | null = null; + layer.addEventListener( + "pointerdown", + (event) => (down = { x: (event as PointerEvent).clientX, y: (event as PointerEvent).clientY }), + true, + ); + layer.addEventListener( + "click", + (event) => { + const { clientX, clientY } = event as MouseEvent; + if (down && Math.hypot(clientX - down.x, clientY - down.y) > DRAG_PX) event.stopPropagation(); + down = null; + }, + true, + ); +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 7f797e240..fbaf418af 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -21,6 +21,7 @@ import { } from "./B06_Section_UI_Cross_Ford_Pavement"; import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire"; import { attachShapePick, shapePickRoute } from "./B06_Section_UI_Cross_Shape_Pick"; +import { stackPickTargets } from "./B06_Section_UI_Cross_Pick_Order"; import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch"; @@ -604,6 +605,7 @@ export function createCrossSectionCard( }); setLineActive = linePick.setActive; setLineActive(pickedLineAt(section.station_id)); + stackPickTargets(plotLayer); // 고르기 차례 = 좁은 것 위 · 보라 선 맨 위(295-1) } // 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한). From 4eccca40d01357c19e730447433a15a25abc3cfc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 22:01:18 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(b06):=20[=EC=83=81=EC=84=B8]=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=B2=AB=20=EB=B3=B4=EA=B8=B0=20=3D=20?= =?UTF-8?q?=EC=A4=91=EC=8B=AC=EC=A0=90=EC=9D=B4=20=EC=B9=B8=20=EA=B0=80?= =?UTF-8?q?=EC=9A=B4=EB=8D=B0=20=C2=B7=20=EA=B7=B8=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=EA=B0=80=20=EB=93=A4=EC=96=B4=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EC=B6=95=EB=A7=88=EB=8B=A4=20=ED=81=B0=20=EC=AA=BD=20=EB=B0=98?= =?UTF-8?q?=ED=8F=AD=20=C2=B7=20[=E2=A4=A2]=20=C2=B7=20=EB=8D=94=EB=B8=94?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EC=9B=90=EB=B3=B5=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20(295-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- A00_Common/b_svg_zoom_pan.ts | 8 ++- B06_Section/B06_Section_UI_Cross_Detail.ts | 14 +++++- .../B06_Section_UI_Cross_Detail_Home.ts | 49 +++++++++++++++++++ resources/tester/helper_164_detail_zoom.cjs | 6 ++- resources/tester/test_164_detail_zoom.py | 4 +- resources/tester/test_266_detail_home.py | 2 +- 6 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Detail_Home.ts diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 5dc6d9c80..72301e3d8 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -63,6 +63,8 @@ export interface SvgZoomPanOptions { initial?: ZoomPanState; /** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */ homeScale?: number; + /** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */ + home?: ZoomPanState; /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ onChange?: (state: ZoomPanState) => void; /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ @@ -88,11 +90,13 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { }; const maxScale = options.maxScale ?? 8; const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale); - const home: ZoomPanState = { + const home: ZoomPanState = options.home ?? { scale: homeScale, tx: (plot.x + plot.width / 2) * (1 - homeScale), ty: (plot.y + plot.height / 2) * (1 - homeScale), }; + /** 축소 한계 — 1배 · 처음 보기가 더 작으면 그만큼. */ + const minScale = Math.min(1, home.scale); let scale = initial?.scale ?? home.scale; let tx = initial?.tx ?? home.tx; let ty = initial?.ty ?? home.ty; @@ -132,7 +136,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { /** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */ const zoomAt = (factor: number, cx: number, cy: number): void => { - const next = Math.min(maxScale, Math.max(1, scale * factor)); + const next = Math.min(maxScale, Math.max(minScale, scale * factor)); tx = cx - ((cx - tx) / scale) * next; ty = cy - ((cy - ty) / scale) * next; scale = next; diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index ad6f93454..1cd162b62 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -37,6 +37,7 @@ import { type ZoomPanState, } from "../A00_Common/b_svg_zoom_pan"; import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; +import { detailHomeView, layerPoints } from "./B06_Section_UI_Cross_Detail_Home"; import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table"; import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { @@ -194,7 +195,7 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT /** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 · * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) · - * 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */ + * 처음 · 원복 = 중심점이 칸 가운데 · 그림 전체(가로 = 카드 반폭 266-1 · 세로 = 그 안 그림)(295-2). */ export function attachDetailZoom( clone: SVGSVGElement, axes: CrossAxesOptions | null, @@ -220,6 +221,16 @@ export function attachDetailZoom( ); }; follow(initial ?? IDENTITY_VIEW); + const plot = { + x: CROSS_PAD.left, + y: CROSS_PAD.top, + width: width - CROSS_PAD.left - CROSS_PAD.right, + height: height - CROSS_PAD.top - CROSS_PAD.bottom, + }; + const home = + markerLine && Number.isFinite(cx) && Number.isFinite(cy) + ? detailHomeView(layerPoints(layer), [cx, cy], plot, homeScale, 16) + : undefined; return attachSvgZoomPan({ svg: clone, layer, @@ -232,6 +243,7 @@ export function attachDetailZoom( wheelZoom: true, initial, homeScale, + home, onChange: (state) => { follow(state); onChange(state); diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Home.ts b/B06_Section/B06_Section_UI_Cross_Detail_Home.ts new file mode 100644 index 000000000..1f98041b0 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Home.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Home.ts + * [상세] 모달 첫 보기(295-2) — 중심점(중심 십자)이 칸 가운데 · 그림 전체가 들어가게 + * 축마다 중심에서 큰 쪽 반폭으로. [⤢] · 더블클릭 원복도 같은 보기. 카드 보기는 그대로. + * + * 가로 반폭 = 카드 반폭(266-1 `homeScale` 이 되좁히던 폭)의 큰 쪽 · 세로 반폭 = 그 가로 안에 + * 그려진 꼭짓점이 중심에서 위 · 아래로 먼 쪽. + * ========================================================================== */ + +import type { ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; + +type Pt = [number, number]; +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** 첫 보기 배율 · 이동 — 중심 `center` 를 `plot` 가운데에. `homeScale` = 1배 대비 카드 반폭 배율. */ +export function detailHomeView( + points: Pt[], + center: Pt, + plot: Rect, + homeScale: number, + maxScale: number, +): ZoomPanState { + const [cx, cy] = center; + const halfX = Math.max(cx - plot.x, plot.x + plot.width - cx) / Math.max(homeScale, 1e-6); + const ys = points.filter(([x]) => Math.abs(x - cx) <= halfX).map(([, y]) => y); + const halfY = Math.max(1, ...ys.map((y) => Math.abs(y - cy))); + const scale = Math.min(plot.width / 2 / halfX, plot.height / 2 / halfY, maxScale); + return { + scale, + tx: plot.x + plot.width / 2 - scale * cx, + ty: plot.y + plot.height / 2 - scale * cy, + }; +} + +/** 그림 층의 꺾은선 · 다각형 꼭짓점(그림 px). */ +export function layerPoints(layer: Element): Pt[] { + return [...layer.querySelectorAll("polyline, polygon")].flatMap((el) => + (el.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)) + .filter((p): p is Pt => p.length === 2 && p.every(Number.isFinite)), + ); +} diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs index c45d6ecb1..f58025313 100644 --- a/resources/tester/helper_164_detail_zoom.cjs +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -92,6 +92,8 @@ function el(tag) { }; return walk(self); }, + // 첫 보기 꼭짓점(295-2) — 이 그림엔 꺾은선 · 다각형 없음 + querySelectorAll: () => [], addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), fire: (type, event) => { const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }; @@ -109,7 +111,7 @@ globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: e const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts")); -// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150) +// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 319,142 = 칸 가운데 · 295-2 첫 보기 = 1배) const AXES = { widthPx: 600, heightPx: 300, @@ -126,7 +128,7 @@ function chart() { const clipGroup = svgElement("g", { "clip-path": "url(#c)" }); const plot = svgElement("g", { class: "b06-chart__plot-layer" }); const center = svgElement("g", { class: "b06-chart__center" }); - center.append(svgElement("line", { x1: 300, y1: 132, x2: 300, y2: 168 })); + center.append(svgElement("line", { x1: 319, y1: 124, x2: 319, y2: 160 })); plot.append(center); clipGroup.append(plot); svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" })); diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index 96e1842e4..58565d78a 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -54,8 +54,8 @@ def test_축_고정_도형_층만_움직임_눈금_다시(out): assert out["axesTransform"] is None and out["clipTransform"] is None assert out["ticksChanged"] is True and out["ticksAfter"] > 0 assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로 - # 확대 — 십자는 중심(300,150)에서 배율 되돌림 - assert out["centerTransform"].startswith("translate(300 150) scale(0.869") + # 확대 — 십자는 중심(319,142 · 칸 가운데)에서 배율 되돌림 + assert out["centerTransform"].startswith("translate(319 142) scale(0.869") assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만 assert out["noLayer"] is None # 도형 층 없음 = 줌 없음 diff --git a/resources/tester/test_266_detail_home.py b/resources/tester/test_266_detail_home.py index 31dcecc2f..f3ea83ef2 100644 --- a/resources/tester/test_266_detail_home.py +++ b/resources/tester/test_266_detail_home.py @@ -81,5 +81,5 @@ def test_배선(): in view ) src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split()) - assert "drawn.homeScale" in src and "homeScale, onChange" in src + assert "drawn.homeScale" in src and "homeScale, home, onChange" in src # 295-2 첫 보기 assert "homeScale: drawn.homeScale" in src From 8b87186a2612073fb9b77eb2cb5efd7c069226f3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 22:02:04 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(b06):=20[=EC=83=81=EC=84=B8]=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=84=A0=20?= =?UTF-8?q?=3D=20=EB=8B=A4=EB=A5=B8=20=EC=84=A0=20=EB=82=98=EB=88=94=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EB=A7=8C=20=C2=B7=20=EA=B3=A0=EB=A5=BC=20?= =?UTF-8?q?=EB=95=90=20=ED=95=9C=20=EC=84=A0=20=ED=86=B5=EC=A7=B8=20=C2=B7?= =?UTF-8?q?=20=EB=8B=AB=ED=9E=8C=20=EC=98=81=EC=97=AD=20=C2=B7=20=EC=98=9B?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=A1=B0=EA=B0=81=20=C2=B7=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=85=88=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20+=20295?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20(295-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B06_Section_UI_Cross_Detail_Geom.ts | 9 +- .../B06_Section_UI_Cross_Detail_Select.ts | 46 ++- .../B06_Section_UI_Style_Cross_Detail.css | 7 +- .../tester/helper_295_pick_order_detail.cjs | 273 ++++++++++++++++++ .../tester/test_295_pick_order_detail.py | 118 ++++++++ 5 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 resources/tester/helper_295_pick_order_detail.cjs create mode 100644 resources/tester/test_295_pick_order_detail.py 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