diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts index 3c77b6975..ed34f7a80 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -14,9 +14,11 @@ import { bindArrowKeys, bindDragHandle, buildPanelShell, + clampAxis, dpadButton, dragReadout, makeRow, + snapAxisDrag, } from "./B06_Section_UI_Cross_Panel_Base"; /** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */ @@ -27,6 +29,8 @@ export interface BoxPanelDeps { nudgeLength: (role: BoxSideRole, screenDeltaM: number) => void; /** 그 끝의 구체 표고 ±0.1m. */ nudgeRise: (role: BoxSideRole, deltaM: number) => void; + /** 그림 끌기(108-2) — 길이(화면 좌 +) · 표고를 한 번에 반영. */ + move: (role: BoxSideRole, screenDeltaM: number, riseDeltaM: number) => void; /** 그 측을 자동 자리로 되돌린다. */ reset: (role: BoxSideRole) => void; /** 이 측이 좌측 「구조물 배치」의 어느 칸인가 — 좌·우는 측점 지형이 정하고, 폼은 @@ -48,9 +52,10 @@ export interface BoxPanelHandle { /** 한 걸음(m) — 사용자 지정 0.1m. */ const STEP_M = 0.1; +const RISE_LIMIT_M = 10; /** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. 값 칸은 없다(계획서 24-4a) — - * 오버레이 = 읽기 + 십자 + 그림 끌기 손잡이 · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */ + * 오버레이 = 제목 + 십자 + 그림 끌기 손잡이(108-1) · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle { const shell = buildPanelShell(() => deps.close()); const { root, title } = shell; @@ -63,7 +68,8 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo const riseLine = document.createElement("span"); const slopeLine = document.createElement("span"); const dragLine = dragReadout(); - value.append(lengthLine, riseLine, slopeLine, dragLine); + // 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만. + value.append(...(opts?.dock ? [lengthLine, riseLine, slopeLine] : []), dragLine); let current: BoxSideRole | null = null; const act = (run: (role: BoxSideRole) => void) => () => { @@ -106,17 +112,27 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo const off = { attach: (): void => undefined, detach: (): void => undefined }; const arrows = opts?.dock ? off : bindArrowKeys(root, () => [moveRow.controls]); - // 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고(십자와 같은 걸음 · 24-4a). + // 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고 두 축 함께(십자와 같은 걸음 · 108-2). + // 한계 = 길이 ≥ 0(바깥으로만) · 표고 ±10 m(제어기 `_Page_Ford_Controls` 와 같음). const drag = opts?.dock ? off : bindDragHandle(root, { - stepM: () => (current ? STEP_M : null), - preview: (text) => (dragLine.textContent = text), - commit: (left, down) => { - if (!current) return; - if (left) deps.nudgeLength(current, left * STEP_M); - if (down) deps.nudgeRise(current, -down * STEP_M); + snap: (leftM, downM) => { + const role = current; + if (!role) return null; + const from = deps.adjustFor(role); + const lengthSign = role === "left" ? 1 : -1; + return snapAxisDrag(leftM, downM, { + stepM: STEP_M, + slopeOutward: null, + clamp: (left, down) => [ + clampAxis(from.lengthM, left * lengthSign, 0, Infinity) * lengthSign, + -clampAxis(from.riseM, -down, -RISE_LIMIT_M, RISE_LIMIT_M), + ], + apply: (left, down) => deps.move(role, left, -down), + }); }, + preview: (text) => (dragLine.textContent = text), }); function render(): void { diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index edf4b76f7..0b31dd209 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -17,9 +17,11 @@ import { bindArrowKeys, bindDragHandle, buildPanelShell, + clampAxis, dpadButton, dragReadout, makeRow, + snapAxisDrag, } from "./B06_Section_UI_Cross_Panel_Base"; /** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */ @@ -32,6 +34,10 @@ export interface FordPanelDeps { nudge: (role: FordWallRole, screenDeltaM: number) => void; /** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */ nudgeSlope: (role: FordWallRole, deltaM: number) => void; + /** 사면 바깥이 화면 왼쪽이면 1 · 오른쪽이면 −1(끌기 축 나누기 · 108-2). */ + outwardOf: (role: FordWallRole) => number; + /** 그림 끌기 — 좌우(화면 좌 +) · 상하(사면) 를 한 번에 반영. */ + move: (role: FordWallRole, screenDeltaM: number, slopeDeltaM: number) => void; /** 자동 자리로 되돌린다(세 축 모두). */ reset: (role: FordWallRole) => void; /** 바닥판 길이(m) — 날개벽 투영이 정한 값을 읽기 전용으로 보여 준다. */ @@ -48,11 +54,13 @@ export interface FordPanelHandle { /** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */ const STEP_M = 0.1; +/** 좌우 · 상하 한계(m) — 제어기 `clampMove`(`_Page_Ford_Controls`)와 같다. */ +const MOVE_LIMIT_M = 10; /** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다. * 관경 · 수량 · 날개벽은 좌측 폼 칸이 정본이라 창에 두지 않는다(계획서 24-4a). * `opts.dock` — 읽기 줄만 만든다(측벽 높이 줄 없음 · 107-3). - * 오버레이는 읽기 + 십자 + 그림 끌기 손잡이. */ + * 오버레이는 제목 + 십자 + 그림 끌기 손잡이(108-1). */ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle { const shell = buildPanelShell(() => deps.close()); const { root, title } = shell; @@ -65,7 +73,8 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): const moveLine = document.createElement("span"); const slabLine = document.createElement("span"); const dragLine = dragReadout(); - value.append(heightLine, moveLine, slabLine, dragLine); + // 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만. + value.append(...(opts?.dock ? [heightLine, moveLine, slabLine] : []), dragLine); let current: FordWallRole | null = null; const act = (run: (role: FordWallRole) => void) => () => { @@ -110,17 +119,27 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): const arrows = opts?.dock ? { attach: (): void => undefined, detach: (): void => undefined } : bindArrowKeys(root, () => [moveRow.controls]); - // 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a). + // 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 1:1.2 사면 두 축 함께(108-2). + // 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 상하 ±10 m. const drag = opts?.dock ? { attach: (): void => undefined, detach: (): void => undefined } : bindDragHandle(root, { - stepM: () => (current ? STEP_M : null), - preview: (text) => (dragLine.textContent = text), - commit: (left, down) => { - if (!current) return; - if (left) deps.nudge(current, left * STEP_M); - if (down) deps.nudgeSlope(current, down * STEP_M); + snap: (leftM, downM) => { + const role = current; + if (!role) return null; + const from = deps.adjustFor(role); + const outward = deps.outwardOf(role); + return snapAxisDrag(leftM, downM, { + stepM: STEP_M, + slopeOutward: outward, + clamp: (left, down) => [ + clampAxis(from.lateralM, left * outward, 0, MOVE_LIMIT_M) * outward, + clampAxis(from.slopeM, down, -MOVE_LIMIT_M, MOVE_LIMIT_M), + ], + apply: (left, down) => deps.move(role, left, down), + }); }, + preview: (text) => (dragLine.textContent = text), }); function render(): void { diff --git a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts index 4e01b65f6..f8b258525 100644 --- a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts +++ b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts @@ -10,6 +10,8 @@ * · 키보드 방향키 = 십자 버튼(2026-08-23) — 셀렉트·입력 포커스 중엔 양보. * ========================================================================== */ +import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const"; + /** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */ export function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement { const button = document.createElement("button"); @@ -89,19 +91,77 @@ export function arrowsBelongToProfile(): boolean { /** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */ const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active'; -/** 끌기 손잡이가 조정 창에 묻는 것 — 걸음(m)이 null 이면 지금 고른 것은 끌 수 없다. */ +/** 끈 자리를 패널 걸음 · 한계로 맞춘 결과 — 화면 이동(m · 왼쪽 + · 아래 +) · 미리 보기 글 · 반영. */ +export interface DragSnap { + leftM: number; + downM: number; + text: string; + apply: () => void; +} + +/** 끌기 손잡이가 조정 창에 묻는 것. */ export interface DragHandleDeps { - stepM: () => number | null; + /** 끈 거리(m · 화면 왼쪽 + · 실제 아래 +) → 맞춘 자리. null = 지금 고른 것은 끌 수 없다. */ + snap: (leftM: number, downM: number) => DragSnap | null; /** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */ preview: (text: string) => void; - /** 놓을 때 한 번 — 화면 왼쪽(◀) 걸음 수 · 아래(▼) 걸음 수. 둘 중 하나만 0 이 아니다. */ - commit: (stepsLeft: number, stepsDown: number) => void; +} + +const round1 = (value: number): number => Math.round(value * 10) / 10; +const clampTo = (value: number, low: number, high: number): number => + Math.min(Math.max(value, low), high); + +/** 십자 두 축 끌기를 맞추는 데 필요한 것 — 걸음 · ▲▼ 축 뜻 · 한계 · 반영. */ +export interface AxisDragSpec { + stepM: number; + /** ▲▼ 한 칸이 1:1.2 사면(바깥 d · 아래 d/1.2)이면 사면 바깥의 화면 방향(왼쪽 = 1) · 순수 수직이면 null. */ + slopeOutward: number | null; + /** 걸음 맞춘 축 이동(◀ + · ▼ +) → 한계 안 가장 가까운 축 이동. */ + clamp: (left: number, down: number) => [number, number]; + apply: (left: number, down: number) => void; +} + +/** 1:1.2 사면 — 사면 축 한 칸 = 바깥 d · 아래 d/1.2(`_Culvert_Solve.placePipeWall` · 집수정과 같음). */ +const SLOPE_RUN = FILL_SLOPE_RATIO_MIN; + +/** + * 끈 거리를 십자 두 축으로 나눠 걸음에 맞춘다(108-2) — ◀▶ 축 + ▲▼ 축을 함께. + * 사면 축이면 아래 성분이 사면 칸을 정하고, 남은 가로가 ◀▶ 칸이 된다. 한계에 걸리면 + * `clamp` 가 가장 가까운 허용 자리로 자르고, 미리 보기도 그 자리(놓은 뒤 결과와 같음)다. + */ +export function snapAxisDrag(leftM: number, downM: number, spec: AxisDragSpec): DragSnap { + const { stepM, slopeOutward: outward } = spec; + const snapStep = (value: number): number => round1(Math.round(value / stepM) * stepM); + const wantDown = snapStep(outward === null ? downM : downM * SLOPE_RUN); + const wantLeft = snapStep(leftM - (outward ?? 0) * wantDown); + const [left, down] = spec.clamp(wantLeft, wantDown).map(round1); + return { + leftM: left + (outward ?? 0) * down, + downM: outward === null ? down : down / SLOPE_RUN, + text: moveText(left, down), + apply: () => spec.apply(left, down), + }; +} + +/** 미리 보기 글 — 십자 단추 뜻 그대로(◀▶ m · ▲▼ m). */ +function moveText(left: number, down: number): string { + return [ + left ? `${left > 0 ? "◀" : "▶"} ${Math.abs(left).toFixed(1)}m` : "", + down ? `${down > 0 ? "▼" : "▲"} ${Math.abs(down).toFixed(1)}m` : "", + ] + .filter(Boolean) + .join(" · "); +} + +/** 축 한계 — 지금 값 `from` 에서 `move` 만큼 가되 [low, high] 안(조정값 공간). */ +export function clampAxis(from: number, move: number, low: number, high: number): number { + return round1(clampTo(from + move, low, high) - from); } /** * 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다. - * 끄는 동안은 도형만 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 다시 그려진다. - * 좌우 · 상하 중 많이 끈 쪽 하나만 쓴다(십자와 같음). 축척은 카드 도면이 적어 둔 `data-px-per-m`. + * 끄는 동안은 도형만 맞춘 자리로 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 + * 다시 그려진다. 좌우 · 상하 두 축을 함께 쓴다(108-2). 축척은 카드 도면이 적어 둔 `data-px-per-m`. */ export function bindDragHandle( root: HTMLElement, @@ -126,33 +186,22 @@ export function bindDragHandle( event.clientY <= box.bottom + 3 ); }); - const stepM = deps.stepM(); const pxPerM = Number(svg.dataset.pxPerM); const exaggeration = Number(svg.dataset.exaggeration) || 1; const ctm = layer.getScreenCTM(); - if (!onShape || !stepM || !(pxPerM > 0) || !ctm) return; + if (!onShape || !deps.snap(0, 0) || !(pxPerM > 0) || !ctm) return; event.preventDefault(); event.stopPropagation(); const [startX, startY] = [event.clientX, event.clientY]; - let left = 0; - let down = 0; + let snapped: DragSnap | null = null; const move = (next: PointerEvent): void => { - const dx = next.clientX - startX; - const dy = next.clientY - startY; - const horizontal = Math.abs(dx) >= Math.abs(dy); - left = horizontal ? Math.round(-dx / (pxPerM * ctm.a) / stepM) : 0; - down = horizontal ? 0 : Math.round(dy / (pxPerM * exaggeration * ctm.d) / stepM); - const tx = -left * stepM * pxPerM; - const ty = down * stepM * pxPerM * exaggeration; + const leftM = -(next.clientX - startX) / (pxPerM * ctm.a); + const downM = (next.clientY - startY) / (pxPerM * exaggeration * ctm.d); + snapped = deps.snap(leftM, downM); + const tx = -(snapped?.leftM ?? 0) * pxPerM; + const ty = (snapped?.downM ?? 0) * pxPerM * exaggeration; shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`)); - const meters = (Math.abs(left || down) * stepM).toFixed(1); - deps.preview( - left - ? `${left > 0 ? "◀" : "▶"} ${meters}m` - : down - ? `${down > 0 ? "▼" : "▲"} ${meters}m` - : "", - ); + deps.preview(snapped?.text ?? ""); }; const up = (): void => { window.removeEventListener("pointermove", move); @@ -160,12 +209,12 @@ export function bindDragHandle( window.removeEventListener("pointercancel", up); shapes.forEach((shape) => shape.removeAttribute("transform")); deps.preview(""); - if (!left && !down) return; + if (!snapped?.text) return; // 놓은 자리의 click 이 「같은 구조물 다시 누름 = 선택 해제」로 번지지 않게 한 번 막는다. const swallow = (click: Event): void => click.stopPropagation(); window.addEventListener("click", swallow, { capture: true, once: true }); setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); - deps.commit(left, down); + snapped.apply(); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index d44abb5d7..1bc6abb03 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -25,6 +25,8 @@ import { dragReadout, makeButton, makeRow, + snapAxisDrag, + type AxisDragSpec, } from "./B06_Section_UI_Cross_Panel_Base"; /** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */ @@ -66,6 +68,8 @@ export interface StructurePanelDeps { canNudge: (key: RevetKey) => boolean; /** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */ fineMove: (key: RevetKey) => boolean; + /** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */ + dragAxes?: (key: RevetKey, basin: boolean) => Omit | null; /** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */ extraState: () => { canAdd: boolean; count: number }; /** 이 카드가 연동으로 넘어온 링크 카드인가 — 단 수는 **기준(소유) 측점**에서만 @@ -456,9 +460,8 @@ export function buildStructurePanel( const head = document.createElement("div"); head.className = "b06-structure-panel__head"; - const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin()); - basinReset.classList.add("b06-structure-panel__basin-reset"); - head.append(title, basinReset, closeButton); + // 집수정 기본값은 위치 십자 가운데 ↺ 하나(108-1 — 머리줄은 제목 · 닫기만). + head.append(title, closeButton); // 오버레이 = 읽기 + 십자(기슭막이) · 위치 십자(집수정)만 — 값 칸이 없다(24-4a). 두 조작 // 세트는 **창 맨 아래**(2026-08-24 사용자) · 동시에 뜨지 않아 같은 자리에 온다. // 사본 = 폼 칸으로 옮겨 붙을 줄만(`_Adjust_Dock`) — 십자는 오버레이 전용(2026-08-29 지시 7). @@ -477,22 +480,16 @@ export function buildStructurePanel( : [basinRow.row, moveRow.row]), ); - // 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a). 오버레이만. + // 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 두 축 함께 · 놓을 때 한 번(108-2). 오버레이만. let dragKind: "wall" | "basin" | null = null; const drag = bindDragHandle(root, { - stepM: () => - !current || !dragKind ? null : dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current), - preview: (text) => (dragLine.textContent = text), - commit: (left, down) => { - if (!current) return; - if (dragKind === "basin") { - if (left) deps.nudgeBasinLateral(left * BASIN_STEP_M); - if (down) moveBasinSlope(down); - return; - } - if (left) deps.nudge(current, left * moveStepOf(current)); - if (down) deps.nudgeSlope(current, down * moveStepOf(current)); + snap: (leftM, downM) => { + const axes = current && dragKind ? deps.dragAxes?.(current, dragKind === "basin") : null; + if (!current || !axes) return null; + const stepM = dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current); + return snapAxisDrag(leftM, downM, { stepM, ...axes }); }, + preview: (text) => (dragLine.textContent = text), }); return { @@ -540,7 +537,6 @@ export function buildStructurePanel( .querySelector(".b06-structure-panel__btn--equal") ?.classList.toggle("is-hidden", isOwn || key !== "outlet"); basinRow.row.classList.toggle("is-hidden", !isBasin); - basinReset.classList.toggle("is-hidden", !isBasin); // I형은 내공(높이·폭) 개념이 없다 — 내공 줄을 숨긴다. for (const entry of basinSizeRows) { // 화면에서 숨김(54-40 · 값 · 그림 계산은 그대로) diff --git a/B06_Section/B06_Section_UI_Cross_View_Box.ts b/B06_Section/B06_Section_UI_Cross_View_Box.ts index f0400d5e5..59790bbdf 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Box.ts @@ -30,6 +30,13 @@ export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps { }), nudgeRise: (role, deltaM) => box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }), + move: (role, screenDeltaM, riseDeltaM) => { + const from = box.adjustFor(chainageM)[role]; + box.update(chainageM, role, { + lengthM: Math.round((from.lengthM + screenDeltaM * (role === "left" ? 1 : -1)) * 10) / 10, + riseM: Math.round((from.riseM + riseDeltaM) * 10) / 10, + }); + }, reset: (role) => box.reset(chainageM, role), wingRoleFor: (role) => ((role === "left") === context.inletOnLeft ? "inlet" : "outlet"), bodyLengthM: () => context.layout()?.bodyLengthM ?? 0, diff --git a/B06_Section/B06_Section_UI_Cross_View_Ford.ts b/B06_Section/B06_Section_UI_Cross_View_Ford.ts index 881dd3c8c..66e70ab11 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Ford.ts @@ -39,6 +39,14 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps { ford.update(chainage, role, { slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM, }), + outwardOf: (role) => outwardOf(section, role), + move: (role, screenDeltaM, slopeDeltaM) => { + const from = ford.adjustFor(chainage)[role]; + ford.update(chainage, role, { + lateralM: from.lateralM + screenDeltaM * outwardOf(section, role), + slopeM: from.slopeM + slopeDeltaM, + }); + }, reset: (role) => ford.reset(chainage, role), slabLengthM: context.slabLengthM, close: context.close, diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index c0518def8..067951139 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -8,10 +8,13 @@ import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { + FILL_SLOPE_RATIO_MIN, + PIPE_WALL_DEFAULT_RUN_M, REVET_EMBED_DEPTH_M, REVET_FORM_DEFAULT, WALL_MIN_PURE_HEIGHT_M, } from "./B06_Section_UI_Cross_Culvert_Const"; +import { clampAxis, type AxisDragSpec } from "./B06_Section_UI_Cross_Panel_Base"; import type { CulvertLayout } from "./B06_Section_UI_Cross_Culvert_Types"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel"; @@ -149,6 +152,7 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe // 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80). fineMove: (key) => (key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()), + dragAxes: (key, basin) => wallDragAxes(ctx, key, basin), extraState: () => ctx.extraState(), isLinkedCard: () => ctx.isLinked, basinExtraState: () => ctx.basinExtraState(), @@ -222,6 +226,70 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe }; } +/** 끌기 한계 ±10 m — 제어기 `clampMove`(`_Page_Station_Controls`)와 같다. */ +const DRAG_LIMIT_M = 10; +const round1 = (value: number): number => Math.round(value * 10) / 10; + +/** + * 벽 · 집수정 그림 끌기 두 축(108-2) — 축 뜻 · 한계 · 한 번 반영. 한계는 저장 · 기하가 거는 것 중 + * 미리 알 수 있는 것(±10 m · 안쪽 금지 · 노면 위 금지 · 다단 선반 1.2·d) — 넘으면 가장 가까운 허용 자리. + * · 배관 기준벽 · 집수정 = ▲▼ 1:1.2 사면(`placePipeWall` · `_Culvert_Basin` 사면 이동) + * · 다단 · 독립 벽 = ▲▼ 순수 수직(`_Culvert_Extra` · `_Revetment` 축) + */ +function wallDragAxes( + ctx: StructurePanelContext, + key: RevetKey, + basin: boolean, +): Omit | null { + if (!ctx.revetOffset) return null; + const outward = ctx.outwardOf(basin ? "inlet" : key); + if (basin) { + const from = ctx.inletStructure?.adjustFor(ctx.section); + if (!from) return null; + const lateralMove = (left: number): number => + clampAxis(from.lateralM, left * outward, 0, DRAG_LIMIT_M); + return { + slopeOutward: outward, + clamp: (left, down) => [ + lateralMove(left) * outward, + clampAxis(from.slopeM, down, 0, DRAG_LIMIT_M), + ], + apply: (left, down) => + ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), { + ...from, + lateralM: round1(from.lateralM + left * outward), + slopeM: round1(from.slopeM + down), + }), + }; + } + const adjust = ctx.adjustOf(key); + const d0 = adjust.d ?? ctx.appliedD().get(key) ?? 0; + const diagonal = key === "inlet" || key === "outlet"; + // x 하한 — 배관 기준벽 = 안쪽 금지(흙막이는 본체 끝이 0점이라 안 막음 · 73-4) · 다단 = 선반 ≥ 0 · + // 독립 = 성토선 각도(1.2·d − 기본 run) + const xLow = (d: number): number => + isTierKey(key) + ? Math.max(0, FILL_SLOPE_RATIO_MIN * d) + : key === "own" + ? FILL_SLOPE_RATIO_MIN * d - PIPE_WALL_DEFAULT_RUN_M + : ctx.bodyGuard?.() + ? -DRAG_LIMIT_M + : 0; + return { + slopeOutward: diagonal ? outward : null, + clamp: (left, down) => { + const dMove = clampAxis(d0, down, 0, DRAG_LIMIT_M); + const low = Math.max(xLow(d0 + dMove), -DRAG_LIMIT_M); + return [clampAxis(adjust.x, left * outward, low, DRAG_LIMIT_M) * outward, dMove]; + }, + apply: (left, down) => + ctx.revetOffset?.update(ctx.adjustChainage(), key, { + x: round1(adjust.x + left * outward), + d: round1(d0 + down), + }), + }; +} + /** * 조정창이 쓸 **카드 상태**를 마지막 기하 결과에서 뽑는다(2026-08-25 분리 — 카드 * 렌더러 700줄). 값 계산은 하지 않고 레이아웃 결과를 조정창 키 체계로 옮기기만 한다. diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index d74b85ad7..c53b382ca 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -174,8 +174,9 @@ grid-area: se; } -.b06-structure-panel__basin-reset { - margin-left: auto; +/* 횡단도 안 조정창 = 제목 · 단추만(108-1) — 줄 이름은 좌측 사본에만. */ +.b06-structure-panel:not(.b06-structure-panel--dock) .b06-structure-panel__label { + display: none; } .b06-structure-panel__select { @@ -297,11 +298,6 @@ display: none; } -/* 집수정 기본값(↺)은 9키와 한 세트라 오버레이에만 둔다. */ -.b06-structure-panel--dock .b06-structure-panel__basin-reset { - display: none; -} - /* 좌측 폼과 중복된 행은 사본에서 숨긴다(2026-08-29 사용자 지시 3·5): * 형태 = 유입구·유출구 그룹의 "형태", 구조물 형식 = 유입구 "구조"로 합쳤다. */ .b06-structure-panel--dock .b06-structure-panel__struct--form, diff --git a/resources/tester/helper_108_drag_box_hit.cjs b/resources/tester/helper_108_drag_box_hit.cjs new file mode 100644 index 000000000..aee466c97 --- /dev/null +++ b/resources/tester/helper_108_drag_box_hit.cjs @@ -0,0 +1,302 @@ +/* PLAN 108 단위검증 헬퍼 — 조정창 끌기 두 축 · 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면. + * TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌. + * 끌기 손잡이(`bindDragHandle`)는 가로채서 창이 넘긴 `snap` 을 그대로 부른다. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. + */ +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(); +/** 가로챌 모듈 — 아래에서 채운다(창 모듈을 부르기 전에). */ +let STUBS = {}; + +// ── 화면 그릇 흉내 — 조정창이 쓰는 만큼만 ───────────────────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + 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), + }; + } + 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); + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + append(...kids) { + kids.forEach((k) => this.children.push(k)); + } + setAttribute(name, value) { + this[name] = value; + } + addEventListener() {} + querySelectorAll(selector) { + const found = []; + const walk = (node) => + node.children.forEach((kid) => { + if (kid.classList && kid.classList.contains(selector.slice(1))) found.push(kid); + walk(kid); + }); + walk(this); + return found; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } +} +globalThis.document = { + createElement: (tag) => new FakeNode(tag), + addEventListener: () => {}, + removeEventListener: () => {}, +}; +globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} }; +globalThis.requestAnimationFrame = (fn) => fn(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + return loadTs(path.join(path.dirname(full), `${request}.ts`)); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`); +const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base")); +/** 창이 넘긴 끌기 deps — 마지막 것이 지금 창. */ +const drags = []; +STUBS = { + "./B06_Section_UI_Section_Common": { L: (key) => key }, + "@ui/ui_template_elements": { showToast: () => {} }, + "./B06_Section_UI_Cross_Panel_Base": { + ...base, + bindDragHandle: (root, deps) => { + drags.push(deps); + return { attach: () => {}, detach: () => {} }; + }, + }, +}; +const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel")); +const { buildBoxPanel } = loadTs(B06("B06_Section_UI_Cross_Box_Panel")); +const { buildFordPanel } = loadTs(B06("B06_Section_UI_Cross_Ford_Panel")); +const { structurePanelDeps } = loadTs(B06("B06_Section_UI_Cross_View_Structure")); +const { placePipeWall } = loadTs(B06("B06_Section_UI_Cross_Culvert_Solve")); +const { boxHitBands } = loadTs(B06("B06_Section_UI_Cross_Box")); + +const r3 = (v) => Math.round(v * 1000) / 1000; +const snapOut = (s) => (s ? { leftM: r3(s.leftM), downM: r3(s.downM), text: s.text } : null); + +// ── 벽 · 집수정 창 — 실제 배선(structurePanelDeps)으로 ───────────────────────── +function wallCase({ key, structure = "revet", outward, adjust, appliedD = 0, basin, guard }) { + const updates = []; + const ctx = { + section: { chainage_m: 100 }, + adjustChainage: () => 100, + isLinked: false, + revetOffset: { update: (at, role, patch) => updates.push({ role, ...patch }) }, + inletStructure: { + valueFor: () => structure, + adjustFor: () => basin ?? { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 }, + updateAdjust: (at, next) => updates.push({ basin: next }), + }, + adjustOf: () => adjust, + outwardOf: () => outward, + heightOfWall: () => 2, + drawnWallKeys: () => [key], + formOfWall: () => "돌쌓기(메)", + appliedD: () => new Map([[key, appliedD]]), + inletIsBasin: () => structure !== "revet", + hiddenPipe: () => false, + bodyGuard: () => !!guard, + fordGuard: () => false, + inletOptions: () => ({ revetAllowed: true, basinLUAllowed: true }), + extraState: () => ({ canAdd: true, count: 1 }), + basinExtraState: () => ({ canAdd: false, count: 0 }), + activeRevet: () => key, + setActiveRevet: () => {}, + toggleRevet: () => {}, + scrollKey: "t", + }; + const panel = buildStructurePanel(structurePanelDeps(ctx)); + panel.show(key); + const drag = drags[drags.length - 1]; + return { + panel, + snap: (leftM, downM) => { + const s = drag.snap(leftM, downM); + if (s?.text) s.apply(); + return { snap: snapOut(s), update: updates.pop() ?? null }; + }, + }; +} + +/** 배관 기준벽 자리(`placePipeWall`) — 미리 보기 = 결과 판정용. 화면 왼쪽 = offset +. */ +const wallAt = (outward, adjust) => + placePipeWall({ + autoOffset: 5 * outward, + outward, + height: 2, + baseElevation0: 98, + edgeElevation: 100, + invertCap: 99, + adjust: { x: adjust.x, d: adjust.d, h: null, m: null }, + defaultD: 0, + }); +const shiftOf = (outward, from, to) => { + const a = wallAt(outward, from); + const b = wallAt(outward, to); + return { leftM: r3(b.anchorOffset - a.anchorOffset), downM: r3(a.topElevation - b.topElevation) }; +}; + +const out = { wall: {}, box: {}, ford: {} }; +for (const outward of [1, -1]) { + const from = { x: 0, d: 2, h: null, m: null }; + const { snap, panel } = wallCase({ key: "outlet", outward, adjust: from }); + // 대각 끌기 — 화면 왼쪽 3.3 · 아래 1.7 (outward = 1 기준), 반대편은 화면 좌우만 뒤집음 + const got = snap(3.3 * outward, 1.7); + out.wall[`diag${outward}`] = { + ...got, + result: got.update ? shiftOf(outward, from, got.update) : null, + overlayHead: panel.root.children[0].children.map((c) => c.className), + }; +} +// 한계 — 사면 위로 넘김 · 안쪽으로 넘김 → 가장 가까운 허용 자리(d 0 · x 0) +out.wall.limit = wallCase({ + key: "outlet", + outward: 1, + adjust: { x: 0.5, d: 0.5, h: null, m: null }, +}).snap(-5, -3); +// 흙막이(세월교 · BOX) — 본체 끝이 0점이라 안쪽(음수 x)도 됨 · ±10 m 끝 +out.wall.guard = wallCase({ + key: "outlet", + outward: 1, + adjust: { x: 0, d: 0, h: null, m: null }, + guard: true, +}).snap(-14, 0); +// 다단 벽 — ▲▼ 순수 수직 · 0.1 걸음 · 선반 x ≥ 1.2·d 가 따라 나감 +out.wall.extra = wallCase({ + key: "extra0", + outward: 1, + adjust: { x: 0, d: 0, h: null, m: null }, +}).snap(0.04, 1.0); +// 집수정 — 1:1.2 사면 · 0.1 걸음 · 좌우 · 사면 ≥ 0 +out.wall.basin = wallCase({ + key: "inlet", + structure: "L", + outward: -1, + adjust: { x: 0, d: null, h: null, m: null }, + basin: { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0.3, slopeM: 0 }, +}).snap(-0.86, 0.25); +out.wall.basinNone = wallCase({ + key: "inlet", + structure: "L", + outward: -1, + adjust: { x: 0, d: null, h: null, m: null }, +}).snap(0.02, -0.01); + +// ── BOX 창 — 길이(화면 좌 +) · 표고 ─────────────────────────────────────────── +function boxCase(role, adjust, leftM, downM) { + const moves = []; + const panel = buildBoxPanel({ + adjustFor: () => adjust, + nudgeLength: () => {}, + nudgeRise: () => {}, + move: (r, screen, rise) => moves.push({ role: r, screen: r3(screen), rise: r3(rise) }), + reset: () => {}, + wingRoleFor: () => "inlet", + bodyLengthM: () => 5, + slopeRatio: () => null, + close: () => {}, + }); + panel.show(role); + const s = drags[drags.length - 1].snap(leftM, downM); + if (s?.text) s.apply(); + return { + snap: snapOut(s), + move: moves[0] ?? null, + value: panel.root.children[1].children.map((c) => c.className || "line"), + }; +} +out.box.left = boxCase("left", { lengthM: 0, riseM: 0 }, 0.47, 0.33); +out.box.right = boxCase("right", { lengthM: 1, riseM: 0 }, -0.47, -0.21); +out.box.shorten = boxCase("left", { lengthM: 0.2, riseM: 9.9 }, -0.9, -0.5); + +// ── 세월교 창 — 좌우 + 1:1.2 사면 · 0.1 걸음 ───────────────────────────────────── +function fordCase(outward, adjust, leftM, downM) { + const moves = []; + const panel = buildFordPanel({ + adjustFor: () => adjust, + heightFor: () => 1.5, + nudge: () => {}, + nudgeSlope: () => {}, + outwardOf: () => outward, + move: (r, screen, slope) => moves.push({ role: r, screen: r3(screen), slope: r3(slope) }), + reset: () => {}, + slabLengthM: () => 3.2, + close: () => {}, + }); + panel.show("inlet"); + const s = drags[drags.length - 1].snap(leftM, downM); + if (s?.text) s.apply(); + return { + snap: snapOut(s), + move: moves[0] ?? null, + value: panel.root.children[1].children.map((c) => c.className || "line"), + }; +} +out.ford = { + diag: fordCase(1, { heightM: null, lateralM: 0, slopeM: 0 }, 0.53, 0.27), + inward: fordCase(-1, { heightM: null, lateralM: 0.2, slopeM: 0 }, 0.9, 0), +}; + +// ── BOX 판정면 — 좌측 끝 날개벽(에이프런 4 → 6) · 우측 끝 날개벽 없음 ─────────── +const layout = { + sides: [ + { role: "left", offset: 4, apron: [{ offset: 4 }, { offset: 6 }] }, + { role: "right", offset: -4, apron: [] }, + ], + topSlab: [{ elevation: 99 }, { elevation: 99 }], + bottomSlab: [{ elevation: 96.5 }, { elevation: 96.5 }], +}; +out.hit = Object.fromEntries( + boxHitBands(layout).map(([role, points]) => [ + role, + { + offsets: [...new Set(points.map((p) => r3(p.offset)))].sort((a, b) => a - b), + elevations: [...new Set(points.map((p) => p.elevation))].sort((a, b) => a - b), + }, + ]), +); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_108_drag_box_hit.py b/resources/tester/test_108_drag_box_hit.py new file mode 100644 index 000000000..d1e417e01 --- /dev/null +++ b/resources/tester/test_108_drag_box_hit.py @@ -0,0 +1,124 @@ +"""PLAN 108 — 횡단도 안 조정창 끌기 두 축 · 패널 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면. + +짝: `B06_Section/B06_Section_UI_Cross_Panel_Base.ts`(`snapAxisDrag` · `bindDragHandle`) · +`_Cross_Structure_Panel.ts` · `_Cross_View_Structure.ts`(벽 · 집수정 축 · 한계) · `_Cross_Box_Panel.ts` · +`_Cross_Box.ts`(`boxHitBands`). 실제 TS 를 `helper_108_drag_box_hit.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_108_drag_box_hit.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("side", ["diag1", "diag-1"]) +def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None: + """배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수.""" + case = out["wall"][side] + assert case["update"] == {"role": "outlet", "x": 1, "d": 4} + assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"] + assert "▼ 2.0m" in case["snap"]["text"] + + +@pytest.mark.parametrize("side", ["diag1", "diag-1"]) +def test_미리보기_자리는_놓은_뒤_자리와_같다(out, side) -> None: + """미리 보기 이동 = `placePipeWall` 로 다시 푼 벽 자리 이동(화면 왼쪽 · 아래).""" + case = out["wall"][side] + assert case["snap"]["leftM"] == case["result"]["leftM"] + assert case["snap"]["downM"] == case["result"]["downM"] + + +def test_한계면_가장_가까운_허용_자리(out) -> None: + """사면 위로 · 안쪽으로 넘겨 끌면 d 0 · x 0 에서 멈춤 — 원래 자리로 돌아가지 않음.""" + case = out["wall"]["limit"] + assert case["update"] == {"role": "outlet", "x": 0, "d": 0} + assert case["snap"] == {"leftM": -1, "downM": -0.417, "text": "▶ 0.5m · ▲ 0.5m"} + + +def test_흙막이는_안쪽도_되고_10m_에서_멈춘다(out) -> None: + assert out["wall"]["guard"]["update"] == {"role": "outlet", "x": -10, "d": 0} + assert out["wall"]["guard"]["snap"]["leftM"] == -10 + + +def test_다단_벽은_순수_수직_선반이_따라_나감(out) -> None: + """다단 ▲▼ = 수직 하강 · x ≥ 1.2·d(선반) — 미리 보기도 그 자리.""" + case = out["wall"]["extra"] + assert case["update"] == {"role": "extra0", "x": 1.2, "d": 1} + assert case["snap"]["leftM"] == 1.2 and case["snap"]["downM"] == 1 + + +def test_집수정은_사면_축_0_1_걸음(out) -> None: + case = out["wall"]["basin"] + assert case["update"]["basin"]["lateralM"] == 0.9 + assert case["update"]["basin"]["slopeM"] == 0.3 + assert case["snap"] == {"leftM": -0.9, "downM": 0.25, "text": "▶ 0.6m · ▼ 0.3m"} + + +def test_걸음_반도_안_끌면_반영_없음(out) -> None: + assert out["wall"]["basinNone"] == { + "snap": {"leftM": 0, "downM": 0, "text": ""}, + "update": None, + } + + +def test_BOX_끌기는_길이_표고_함께(out) -> None: + assert out["box"]["left"]["move"] == {"role": "left", "screen": 0.5, "rise": -0.3} + assert out["box"]["left"]["snap"]["leftM"] == 0.5 + assert out["box"]["right"]["move"] == {"role": "right", "screen": -0.5, "rise": 0.2} + + +def test_BOX_한계_길이_0_표고_10(out) -> None: + """안으로 줄이면 길이 0 에서 · 표고는 +10 m 에서 멈춤.""" + assert out["box"]["shorten"]["move"] == {"role": "left", "screen": -0.2, "rise": 0.1} + + +def test_세월교_끌기는_좌우_사면_함께(out) -> None: + """사면 d 0.3 · 좌우 0.2(0.1 걸음) — 미리 보기 = 좌 0.2 + 0.3 · 아래 0.3/1.2.""" + case = out["ford"]["diag"] + assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0.3} + assert case["snap"] == {"leftM": 0.5, "downM": 0.25, "text": "◀ 0.2m · ▼ 0.3m"} + + +def test_세월교_노견_안쪽은_0_에서_멈춤(out) -> None: + """좌우 0.2 에서 안쪽으로 0.9 끌어도 0 까지만(가장 가까운 허용 자리).""" + case = out["ford"]["inward"] + assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0} + assert case["snap"]["leftM"] == 0.2 + + +def test_조정창은_제목과_단추만(out) -> None: + """머리줄 = 제목 · 닫기(집수정 ↺ 없음) · BOX 오버레이 읽기 줄 = 끄는 중 미리 보기 하나.""" + assert out["wall"]["diag1"]["overlayHead"] == [ + "b06-structure-panel__title", + "b06-structure-panel__btn b06-structure-panel__close", + ] + assert out["box"]["left"]["value"] == ["b06-structure-panel__drag"] + assert out["ford"]["diag"]["value"] == ["b06-structure-panel__drag"] + + +def test_BOX_끝_판정면은_본체로_안_넘어감(out) -> None: + """본체 = 두 구체 끝 사이 · 끝 = 구체 끝 ~ 에이프런 바깥 · 날개벽 없는 끝 = 바깥 0.3 m 띠.""" + hit = out["hit"] + assert hit["body"]["offsets"] == [-4, 4] + assert hit["left"]["offsets"] == [4, 6] + assert hit["right"]["offsets"] == [-4.3, -4] + for role in ("body", "left", "right"): + assert hit[role]["elevations"] == [96.5, 99]